メタプログラミングとは?
簡単に言うと、「言語やランタイムそのものの挙動を、コードでカスタマイズ・拡張するプログラミング手法」です。
普通のコード:
- 「データ」を処理するコードを書く
メタプログラミング:
- 「コードや言語の振る舞い」を処理するコードを書く(= コードを“対象”にする)
JavaScriptでの代表的な手段:
Symbol(特に well-known symbol)ProxyとReflect- タグ付きテンプレート
- (他にも
eval,Functionコンストラクタ、decorator 提案など)
この記事では「安全&実用寄りの範囲」に絞って解説します。
Symbolの概観
Symbolとは?
Symbol は ES2015 で導入された 「一意な識別子」 を表すプリミティブ型です。
const s1 = Symbol("id");
const s2 = Symbol("id");
console.log(s1 === s2); // false(description が同じでも別物)特徴:
- 文字列や数値とは別の 新しいプリミティブ型
- 同じ description を渡しても、毎回異なる値
- オブジェクトの「衝突しない秘密プロパティキー」としてよく使われる
const ID = Symbol("id");
const user = {
name: "Taro",
[ID]: 123
};
console.log(user[ID]); // 123
console.log(Object.keys(user)); // ["name"] → Symbolキーは列挙されないwell-known symbol とは?
Symbol.iterator のような あらかじめ仕様で予約されている特殊な symbol をwell-known symbol と呼びます。
well-known symbolは、ある Symbol をプロパティキーとして実装すると「言語の機能」がそのオブジェクトを特別扱いしてくれます。
この記事では、次の4つを解説します。
Symbol.iteratorSymbol.toStringTagSymbol.toPrimitiveSymbol.hasInstance
Symbol.iterator
const range = {
from: 1,
to: 3,
*[Symbol.iterator]() {
for (let n = this.from; n <= this.to; n++) {
yield n;
}
}
};
for (const n of range) {
console.log(n); // 1 2 3
}ここで起きていること:
for...ofは、オブジェクトにSymbol.iteratorプロパティがあれば、それを呼び出してイテレータを得る(言語仕様)[Symbol.iterator]を実装することで、for...ofに“自作型の振る舞い”を教えている
つまり、「イテレーションという言語機能に、自分の型を正式参加させるフック」が Symbol.iterator です。
Symbol.toStringTag:Object.prototype.toString をカスタマイズ
Object.prototype.toString.call(obj) は、
オブジェクトの「内部クラス」のようなものを文字列で返します。
console.log(Object.prototype.toString.call({})); // [object Object]
console.log(Object.prototype.toString.call([])); // [object Array]
console.log(Object.prototype.toString.call(new Date)); // [object Date]これを 自作オブジェクトでカスタマイズ できるのが Symbol.toStringTag。
class MyCollection {
get [Symbol.toStringTag]() {
return "MyCollection";
}
}
const c = new MyCollection();
console.log(Object.prototype.toString.call(c));
// [object MyCollection]使い所:
- ライブラリで「型名っぽいもの」を表現したいとき
- デバッグ・ログ出力で、オブジェクトの種類を判別しやすくする
実務ではそんなに頻繁ではないですが、「言語標準の Object.prototype.toString 振る舞いをフックできる」というメタ的なポイントが重要です。
Symbol.toPrimitive:プリミティブ変換の制御
オブジェクトが「数値や文字列に変換される」場面は多くあります:
+演算(数値加算 or 文字列結合)Number(obj)/String(obj)/ テンプレートリテラル${obj}- 比較演算
><など
デフォルトでは、
obj.valueOf()を試す- ダメなら
obj.toString()を試す
…という挙動ですが、Symbol.toPrimitive を実装すると、ここを完全にカスタマイズできます。
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
[Symbol.toPrimitive](hint) {
if (hint === "number") {
return this.celsius;
}
if (hint === "string") {
return `${this.celsius}°C`;
}
// "default" のときなど
return this.celsius;
}
}
const t = new Temperature(25);
console.log(String(t)); // "25°C"
console.log(+t); // 25(numberコンテキスト)
console.log(`Now: ${t}`); // "Now: 25°C"
console.log(t > 20); // truehint には "number" | "string" | "default" が入ります。
使い所:
- 金額クラス・ベクトルクラス・日付ラッパなどで、「算術演算や比較で直感的に動いてほしい」場合
- ログ・テンプレート文字列で、読みやすい表現を返したい場合
注意点:
やりすぎると「何がどう変換されているのか分かりにくくなる」ので、チーム開発では慎重に使いましょう。
Symbol.hasInstance:instanceof の挙動をカスタマイズ
instanceof 演算子は、通常は「右辺の prototype チェーンを辿って判定」しますが、
右辺に Symbol.hasInstance が実装されていると、そのメソッドが呼ばれます。
class EvenNumber {
static [Symbol.hasInstance](instance) {
return typeof instance === "number" && instance % 2 === 0;
}
}
console.log(2 instanceof EvenNumber); // true
console.log(3 instanceof EvenNumber); // false
console.log("2" instanceof EvenNumber); // falseここで起きていること:
x instanceof EvenNumberを評価すると、エンジンはEvenNumber[Symbol.hasInstance](x)を呼ぶ
つまり、「instanceof という言語演算子の意味を、自分で定義し直せる」というかなり強力なフックです。
実務での使い所は限定的ですが:
- ライブラリで「このオブジェクトは自分のクラスの一種とみなせるか?」を柔軟に定義
- インターフェース的な判定(duck typing っぽい)に使う
など、言語レベルの仕組みに直結しています。
Proxy と Reflect:オブジェクト操作のフック
Proxy とは?
Proxy とは、オブジェクトへの操作(プロパティ取得・代入・in・new など)を、ハンドラ(trap)で横取りできるラッパーのことです。
基本形:
const target = { x: 1 };
const handler = {
get(target, prop, receiver) {
console.log(`get: ${String(prop)}`);
return target[prop];
}
};
const proxy = new Proxy(target, handler);
console.log(proxy.x); // "get: x" とログが出てから 1 を返すここでやっていること:
proxy.xと書くと、直接target.xにはアクセスせず、handler.get(target, "x", proxy)が呼ばれる- その中で
target[prop]を返している
対象オブジェクト(target) とハンドラ(handler) を分けるのがデザイン上のポイントです。
よく使う trap
代表的な trap(ハンドラメソッド):
get(target, prop, receiver):プロパティ取得(obj.prop/obj["prop"])set(target, prop, value, receiver):プロパティ代入has(target, prop):prop in objdeleteProperty(target, prop):delete obj[prop]ownKeys(target):Object.keys,Object.getOwnPropertyNames,Object.getOwnPropertySymbolsapply(target, thisArg, args):関数として呼び出されたときconstruct(target, args, newTarget):newされたとき
すべて実装する必要はなく、必要なものだけ書けばOK です。
Reflect とは?
Reflect は、オブジェクトに対する“元々の動作”を関数として提供するユーティリティです。
例:
Reflect.get(target, prop, receiver)→ 通常のプロパティ取得Reflect.set(target, prop, value, receiver)→ 通常の代入Reflect.has(target, prop)→prop in targetと同等Reflect.ownKeys(target)→Object.getOwnPropertyNames+getOwnPropertySymbols
Proxy の trap の中で、「標準どおりに動かしたい部分は Reflect.xxx に委譲する」というのが定番パターンです。
パターン1:ログ付きオブジェクト
function createLoggingProxy(obj) {
return new Proxy(obj, {
get(target, prop, receiver) {
console.log(`GET ${String(prop)}`);
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`SET ${String(prop)} =`, value);
return Reflect.set(target, prop, value, receiver);
}
});
}
const user = { name: "Taro", age: 20 };
const p = createLoggingProxy(user);
p.name; // GET name
p.age = 21; // SET age = 21- こういう「アクセスログ」「デバッグ」系は Proxy の分かりやすい用途です。
- 元の挙動はすべて
Reflectに任せているので、動きも素直。
パターン2:バリデーション・読み取り専用
年齢プロパティを 0〜150 の範囲に制約する例:
function createUser(user) {
return new Proxy(user, {
set(target, prop, value, receiver) {
if (prop === "age") {
if (typeof value !== "number" || value < 0 || value > 150) {
throw new RangeError("age は 0〜150 の数値である必要があります");
}
}
return Reflect.set(target, prop, value, receiver);
}
});
}
const user = createUser({ name: "Taro", age: 20 });
user.age = 30; // OK
// user.age = -5; // RangeError完全読み取り専用オブジェクトにする例:
function readonly(obj) {
return new Proxy(obj, {
set(target, prop, value) {
console.warn(`プロパティ ${String(prop)} は読み取り専用です`);
return false; // strict mode では TypeError になる
},
deleteProperty(target, prop) {
console.warn(`プロパティ ${String(prop)} は削除できません`);
return false;
}
});
}
const config = readonly({ apiUrl: "https://example.com" });
// config.apiUrl = "x"; // 警告 & 失敗
// delete config.apiUrl; // 警告 & 失敗パターン3:配列の負インデックス
const array = [10, 20, 30];
const indexed = new Proxy(array, {
get(target, prop, receiver) {
if (typeof prop === "string") {
const index = Number(prop);
if (Number.isInteger(index) && index < 0) {
// 負のインデックスなら末尾から
return target[target.length + index];
}
}
return Reflect.get(target, prop, receiver);
}
});
console.log(indexed[-1]); // 30
console.log(indexed[-2]); // 20- 「言語にない構文(負インデックス)を、Proxy を使って“擬似的に”追加する」イメージ
- こういった DSL っぽい拡張も、メタプログラミングです。
タグ付きテンプレート
普通のテンプレートリテラル
const name = "Taro";
const msg = `Hello, ${name}!`;タグ付きテンプレートは、テンプレートの前に関数名を書く構文です。
const result = tag`Hello, ${name}!`;ここで呼ばれるのは:
function tag(strings, ...values) { ... }strings:リテラル部分の配列values:${...}で埋め込まれた値の配列
例:
const name = "Taro";
const count = 3;
function debugTag(strings, ...values) {
console.log(strings); // ["Hello, ", "! You have ", " messages.", ""]
console.log(values); // ["Taro", 3]
return "任意の戻り値";
}
const res = debugTag`Hello, ${name}! You have ${count} messages.`;つまり、テンプレートリテラルを 「パース済みの形」で関数に渡せる のがタグ付きテンプレートです。
例:HTMLエスケープ関数
XSS対策などでよく出てくるパターン:
function escapeHtml(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function html(strings, ...values) {
return strings.reduce((result, s, i) => {
const v = values[i] !== undefined ? escapeHtml(String(values[i])) : "";
return result + s + v;
}, "");
}
const userInput = '<script>alert("XSS")</script>';
const safeHtml = html`<p>${userInput}</p>`;
console.log(safeHtml);
// => "<p><script>alert("XSS")</script></p>"htmlタグ付きテンプレートを通すだけで、${...}で埋め込まれた部分をすべてエスケープ- 「テンプレート文字列をパーサの材料にする」というメタっぽい使い方
例:i18n(多言語化)っぽい使い方
const dict = {
ja: {
greet: (name) => `こんにちは、${name} さん`,
},
en: {
greet: (name) => `Hello, ${name}!`,
}
};
let lang = "ja";
function i18n(strings, ...values) {
// ここでは単純化して、「キーを1個埋め込む」だけの例
const key = values[0]; // `${"greet"}`
return dict[lang][key]("Taro");
}
console.log(i18n`${"greet"}`); // こんにちは、Taro さん
lang = "en";
console.log(i18n`${"greet"}`); // Hello, Taro!
実務ではもっと複雑な仕組みになりますが、
「テンプレート → 解析 → 好きなロジックに流す」
というパイプラインを組めるのがタグ付きテンプレートの本質です。
>>次へ()
コメント