TypeScriptの型宣言を実行時に取り出す

# 背景

TypeScriptの型宣言（プロパティの型、引数や戻り値の型）を実行時に取り出すことはできるのかなと思って調べてみました。どうもできるようなのですが、出てくる情報を見てもいまいちピンときません。他言語のリフレクションのように

```typescript
MyClass.getPropertyType('myProperty')  // => URL
```

みたいなAPIを想像していたのですが、なぜかデコレータの話が始まるのです。どういうことでしょうか。

# 結論

結論から言うと、上の想像に近いことは以下のようなコードで可能です:

```typescript
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` が必要になります:

```json:tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
```

`reflect-metadata` もyarnやnpmで入れておいてください。

# 解説

さて、結局デコレータが出てきました。上のコードの `@property` というやつがそれです。詳しくは説明しないので[公式ドキュメント](https://www.typescriptlang.org/docs/handbook/decorators.html)（[日本語訳](https://mae.chab.in/archives/59845)）をどうぞ。しかしなぜデコレータが必要なのでしょうか。

まず背景として、TypeScriptの型宣言というのは基本的にコンパイル時の型チェックのためのもので、実行時には残りません。これを `emitDecoratorMetadata` を指定することで、 `design:type` などのメタデータとして実行時に残るようになります。[メタデータのAPIはこちら](https://rbuckton.github.io/reflect-metadata/)。

ただしこのメタデータが付くのは、何かのデコレータがついてるメンバ（またはクラス自身）だけのようです。というわけで、上の例では何もしない（空の）デコレータを付けました。これでも型宣言を取り出せるようになります。

このことから、制約として、（デコレータがついていない）既存のクラスの型宣言を取り出すことはできません。他にも以下のような制約があるようです。

* ジェネリック型の型引数を取り出すことはできない。`number[]`と宣言されていても、取り出せるのは`Array`だけです。
* 引数名を取り出すことはできない。引数名を取り出したければ、デコレーションを使って別途引数名を残す必要がある。例:

    ```typescript
    myMethod(@named('a') a: number, @named('b') b: string) { ... }
    ```

* （メソッドではない）関数には適用できない。そもそも関数にはデコレータを付けられない。

# クラスのメンバを列挙する

やや余談ですが、クラスのメンバ（プロパティやメソッド）を列挙しようとして気づいたのが、TypeScriptで宣言されただけの（ゲッタなどを持たない）プロパティを列挙する方法が（おそらく）ないということです。

```typescript
class MyClass {
  myProperty: URL;  // こういうやつ
}
```

メソッドなどは`MyClass.prototype`に入っているのですが、宣言されただけのプロパティは実行時の実体を持たないためです。

今回はどのみちデコレータを付ける必要があるので、デコレータの中でプロトタイプのメタデータにプロパティ名を記録すれば解決です。

```typescript
function property(target: any, propertyKey: string) {
  const properties = Reflect.getOwnMetadata('custom:properties', target) ?? [];
  properties.push(propertyKey);
  Reflect.defineMetadata('custom:properties', properties, target);
}
```

# 全部入りのコード例

上に書いたことを全部入れたコード例がこちらです。与えられたクラスの各メンバの型宣言（引数名を含む）を出力する関数 `dumpTypeDeclaration()` を定義しています。

```typescript
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` という名前ですし、デコレータで使うことを想定された機能なんでしょうね。あらゆるクラスに型宣言のメタデータをつけるのは無駄すぎるというのもあるかもしれません。

# 参考

* [TypeScriptの型は幻ではない！　~ 型宣言を実行中のチェックに使う ~](https://qiita.com/SoraKumo/items/5a756112aab4950a7c49)
* [TypeScript - Documentation: Decorators](https://www.typescriptlang.org/docs/handbook/decorators.html)（[日本語訳](https://mae.chab.in/archives/59845)）
* [Metadata Proposal - ECMAScript](https://rbuckton.github.io/reflect-metadata/)