背景
TypeScriptの型宣言(プロパティの型、引数や戻り値の型)を実行時に取り出すことはできるのかなと思って調べてみました。どうもできるようなのですが、出てくる情報を見てもいまいちピンときません。他言語のリフレクションのように
MyClass.getPropertyType('myProperty') // => URL
みたいなAPIを想像していたのですが、なぜかデコレータの話が始まるのです。どういうことでしょうか。
結論
結論から言うと、上の想像に近いことは以下のようなコードで可能です:
import 'reflect-metadata';
// 何もしないデコレータ。
function property(target: any, propertyKey: string) {}
class MyClass {
@property
myProperty: URL;
@property
set myAccessor(value: URL) { this.myProperty = value; }
get myAccessor() { return this.myProperty; }
@property
myMethod(a: number, b: string): number {
return 0;
}
}
console.log(Reflect.getMetadata('design:type', MyClass.prototype, 'myProperty'));
// => [class URL]
console.log(Reflect.getMetadata('design:type', MyClass.prototype, 'myAccessor'));
// => [class URL]
console.log(Reflect.getMetadata('design:returntype', MyClass.prototype, 'myMethod'));
// => [Function: Number]
console.log(Reflect.getMetadata('design:paramtypes', MyClass.prototype, 'myMethod'));
// => [ [Function: Number], [Function: String] ]
ちなみにこれはTypeScriptの実験的な機能なので、以下のような tsconfig.json
が必要になります:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
reflect-metadata
もyarnやnpmで入れておいてください。
解説
さて、結局デコレータが出てきました。上のコードの @property
というやつがそれです。詳しくは説明しないので公式ドキュメント(日本語訳)をどうぞ。しかしなぜデコレータが必要なのでしょうか。
まず背景として、TypeScriptの型宣言というのは基本的にコンパイル時の型チェックのためのもので、実行時には残りません。これを emitDecoratorMetadata
を指定することで、 design:type
などのメタデータとして実行時に残るようになります。メタデータのAPIはこちら。
ただしこのメタデータが付くのは、何かのデコレータがついてるメンバ(またはクラス自身)だけのようです。というわけで、上の例では何もしない(空の)デコレータを付けました。これでも型宣言を取り出せるようになります。
このことから、制約として、(デコレータがついていない)既存のクラスの型宣言を取り出すことはできません。他にも以下のような制約があるようです。
-
ジェネリック型の型引数を取り出すことはできない。
number[]
と宣言されていても、取り出せるのはArray
だけです。 -
引数名を取り出すことはできない。引数名を取り出したければ、デコレーションを使って別途引数名を残す必要がある。例:
myMethod(@named('a') a: number, @named('b') b: string) { ... }
-
(メソッドではない)関数には適用できない。そもそも関数にはデコレータを付けられない。
クラスのメンバを列挙する
やや余談ですが、クラスのメンバ(プロパティやメソッド)を列挙しようとして気づいたのが、TypeScriptで宣言されただけの(ゲッタなどを持たない)プロパティを列挙する方法が(おそらく)ないということです。
class MyClass {
myProperty: URL; // こういうやつ
}
メソッドなどはMyClass.prototype
に入っているのですが、宣言されただけのプロパティは実行時の実体を持たないためです。
今回はどのみちデコレータを付ける必要があるので、デコレータの中でプロトタイプのメタデータにプロパティ名を記録すれば解決です。
function property(target: any, propertyKey: string) {
const properties = Reflect.getOwnMetadata('custom:properties', target) ?? [];
properties.push(propertyKey);
Reflect.defineMetadata('custom:properties', properties, target);
}
全部入りのコード例
上に書いたことを全部入れたコード例がこちらです。与えられたクラスの各メンバの型宣言(引数名を含む)を出力する関数 dumpTypeDeclaration()
を定義しています。
import 'reflect-metadata';
function property(target: any, propertyKey: string) {
// プロトタイプのメタデータにプロパティを記録。
const properties = Reflect.getOwnMetadata('custom:properties', target) ?? [];
properties.push(propertyKey);
Reflect.defineMetadata('custom:properties', properties, target);
}
// メタデータにパラメータ名を記録するデコレータ。
function named(name: string) {
return (target: any, propertyKey: string, index: number) => {
const paramNames =
Reflect.getOwnMetadata('custom:paramnames', target, propertyKey) ?? [];
paramNames[index] = name;
Reflect.defineMetadata('custom:paramnames', paramNames, target, propertyKey);
};
}
function dumpTypeDeclaration(klass: Function) {
const propertyKeys = Reflect.getMetadata('custom:properties', klass.prototype);
for (const propertyKey of propertyKeys) {
const propertyType = Reflect.getMetadata('design:type', klass.prototype, propertyKey);
const returnType = Reflect.getMetadata('design:returntype', klass.prototype, propertyKey);
const paramTypes = Reflect.getMetadata('design:paramtypes', klass.prototype, propertyKey);
const paramNames = Reflect.getMetadata('custom:paramnames', klass.prototype, propertyKey);
console.log(`${propertyKey}: ${propertyType.name}`);
if (paramTypes !== undefined && paramNames !== undefined) {
console.log(' params:');
for (let i = 0; i < paramTypes.length; ++i) {
console.log(` ${paramNames[i]}: ${paramTypes[i].name}`);
}
}
if (returnType !== undefined) {
console.log(` returns: ${returnType.name}`);
}
}
}
class MyClass {
@property
myProperty: URL;
@property
set myAccessor(value: URL) { this.myProperty = value; }
get myAccessor() { return this.myProperty; }
@property
myMethod(@named('a') a: number, @named('b') b: string): number {
return 0;
}
}
dumpTypeDeclaration(MyClass);
出力:
myProperty: URL
myAccessor: URL
myMethod: Function
params:
a: Number
b: String
returns: Number
感想
どうも制約が多くてちょっと使いにくい印象です。実験的な機能だからというのもあるでしょうか。
特に、デコレータがついてないとメタデータがつかない(逆に、何もしないメタデータでもついていればいい)というのはなんか妙な気がします。理由は知りませんが、 emitDecoratorMetadata
という名前ですし、デコレータで使うことを想定された機能なんでしょうね。あらゆるクラスに型宣言のメタデータをつけるのは無駄すぎるというのもあるかもしれません。