デコレーター

デコレーターは名前の前に@を付けて使います。(例えば@Foo

デコレーターには次の5つのタイプがあります。

  1. クラスデコレーター
  2. プロパティデコレーター
  3. メソッドデコレーター
  4. アクセサデコレーター
  5. パラメーターデコレーター

そしてこれらは範囲の狭いものから順番に適用されます。例えばパラメーターデコレーターからメソッドデコレーター、クラスデコレーターという感じです。

デコレーターを使う前にいくつか準備が必要です。

準備

依存の追加

Reflect API を使う場合、reflect-metadataという Reflect API のポリフィルが必要なのでこれをインストールします。

npm i reflect-metadata
# または
yarn add reflect-metadata

tsconfig.json の編集

以下の2つのオプションを有効にします。

{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true
}

クラスデコレーター

クラスに適用するデコレーターです。クラスデコレーター第1引数を適当先のクラスとして呼び出されます。

displayNameという静的プロパティをデコレーターで追加する例です。

interface DisplayName {
  displayName: string;
}

function DisplayName<
  T extends {new (...args: any[]): any}
>(name: string) {
  return (
    Class: T,
  ): T & DisplayName => {
    return class extends Class {
      static displayName = name;
    } as T & DisplayName;
  };
}

interface IButtonComponent {}

@DisplayName('Button')
class ButtonComponent {}

const ButtonComponentWithDisplayName: DisplayName = ButtonComponent as typeof ButtonComponent &
  DisplayName;

console.log(
  ButtonComponentWithDisplayName.displayName,
);
// 'Button'

ButtonComponentクラスに対して、DisplayNameデコレーターを適用してます。このDisplayNameは正しくはデコレーターファクトリーと呼ばれるものです。

デコレーターファクトリーは、引数で好きな値を受け取ってデコレーターを返す関数のことで、結果をResultと置くと、結果的に@Resultの形に収まり問題ない為よく使われてます。ということで、(Class: T): T & DisplayName => { ... }となっている部分がデコレーター本体です。
クラスデコレーターの場合、デコレーターには適用先のクラスが引数で渡ってきます。つまり、上記のコードの場合ClassButtonComponentです。

クラスデコレーターでは戻り値が、適用先のクラスに置き換わります。なので、デコレーター内部ではデコレーターファクトリーやデコレーターによって受け取った引数の値やクラスを使ってゴニョゴニョしたクラスを戻り値とする必要があります。
例では、ButtonComponentを拡張して、displayName静的プロパティを追加したクラスを作ってます。

プロパティデコレーター

プロパティに適用するデコレーターのことです。プロパティデコレーターは第1引数をプロトタイプとなるオブジェクト、第2引数をプロパティの名前として呼び出されます。

です。

import 'reflect-metadata';

class Foo {}

type TypeValueDectionary = Record<
  string,
  unknown
>;
const typeValueDectionary: TypeValueDectionary = {
  String: 'foo',
  Number: 123,
  Boolean: true,
  Object: {},
  Foo: new Foo(),
};

const typeValueMetadataKey = Symbol(
  'TypeValue',
);

function TypeValue(
  prototypeObject: object,
  propertyName: string,
) {
  // ここで以下のようにしても
  // `extended`値は追加されない
  Object.defineProperty(
    prototypeObject,
    'extended',
    {
      value: 'value',
    },
  );

  const Class: {
    new (...args: any[]): any;
  } = Reflect.getMetadata(
    'design:type',
    prototypeObject,
    propertyName,
  );

  Reflect.defineMetadata(
    typeValueMetadataKey,
    typeValueDectionary[Class.name],
    prototypeObject,
    propertyName,
  );
}

class ButtonComponent {
  @TypeValue
  _textContent!: Foo;

  get textContent(): TypeValueDectionary[string] {
    return Reflect.getOwnMetadata(
      typeValueMetadataKey,
      ButtonComponent.prototype,
      '_textContent',
    );
  }
}

console.log(
  new ButtonComponent().textContent,
);

これは_textContentの型を見て、その型名がキーとなる値をtypeValueDectionaryから取得して表示するコードです。

@TypeValueがプロパティデコレーターで、今はFoo型のプロパティ_textContentに対して設定されてます。このように呼び出すとTypeValueTypeValue(ButtonComponent.prototype, '_textContent')のような形で実行されます。

TypeValueでは、まずReflect#getMetadataメソッドを使ってdesign:typeキーに登録されてる_textxContentのコンストラクター(つまりFoo)を取得してます。「いつそんな登録したんだ」という話ですが、これは TypeScript がデコレーターを使用してる箇所を以下のように継ぎ足しす為自動で登録されます。

class ButtonComponent {
  @TypeValue
  @Reflect.metadata('design:type', Foo)
  _textContent!: Foo;
}

少し説明を足すと、Reflect#metadataもデコレーターファクトリーになっていて、中で「このクラスのこのキー」のように目星を付けてデータを登録してます。

次にReflect#defineMetadataメソッドを使って自分である場所に目星を付けてデータを登録します。このメソッドで登録した値はReflect#getOwnMetadataメソッドで取ってこれます。

登録するのに必要な値は、

  1. キーシンボル
  2. 登録したい値
  3. 目星をつける対象のオブジェクト
  4. そのキー名

の4つです。1はSymbolで作り、3と4に関してはデコレーターで渡された値を使います。

登録した値を取得する為textContentゲッターを定義し、その中でReflect#getOwnMetadataメソッドで値を取ってそれを返します。ここではプロパティ名を性格なプロパティ名を渡す必要があります。

これで_textContent: stringの場合は、

  1. Reflect.getMetadata('design:type', ...)String取得
  2. それからtypeValueDectionary.Stringの値'foo'を登録
  3. ゲッタで'foo'を返す

というように実行されます。

メソッドデコレーター

メソッドに適用するデコレーターのことです。メソッドデコレータは3つの引数を取ります。第1、第2引数はプロパティデコレーターと同じくプロトタイプとなるオブジェクトと対象のキー名、そして第3引数にデータディスクリプターなプロパティディスクリプターが来ます。

また、このメソッドデコレーター(や次のアクセサデコレーター)ではObject#definePropertyを使ったプロパティディスクリプターの更新ができます。

です。

function ReturnValue<T>(value: T) {
  return (
    prototypeObject: object,
    propertyName: string,
    descriptor: PropertyDescriptor
  ) => {
    descriptor.value = () => value;

    Object.defineProperty(
      prototypeObject,
      propertyName,
      descriptor
    );
  };
}

interface ButtonComponentLandmark {
  foo: number;
}

class ButtonComponent {
  value: string = 'button';

  @ReturnValue<ButtonComponentLandmark>(
    {
      foo: 123
    }
  )
  getLandmark(): ButtonComponentLandmark {
    return 'あとで' as any;
  }
}

console.log(
  new ButtonComponent().getLandmark()
);

上記のコードの@ReturnValueがデコレーターファクトリーです。デコレーターファクトリーに渡した値が単に関数の戻り値となるように実装してます。
このデコレーターは、開発初期に中身は実装してないが全体を通して処理したい時に型は通しつつ仮の値で通したいという目的を想定して作られてます。

これをButtonComponent#getLandmarkに対して適用するとデコレーターは(ButtonComponent.prototype, 'getLandmark', {/* descriptor */})のような形で呼ばれます。

メソッドデコレーターの時はデータディスクリプターとなる為、値だけ変更したデータディスクリプターを対象のプロパティで再定義すれば、他のオプションは本来のものと同じ、動作だけ異なる関数が設定できます。
ここでは単にデコレーターファクトリーで受け取った値を返すだけの関数で上書きしてます。また、再定義する関数はなるべく本来の関数と同じ型に合わせると良いと思います。

そんなわけで、getLandmarkを実行すると{foo: 123}と返す関数にできました。

アクセサデコレーター

アクセサデコレーターはゲッターセッターメソッドに対して適用したデコレーターのことです。これはメソッドデコレーターとほぼ同じです。違いはデコレーターのタイプがデータディスクリプターではなくアクセサディスクリプターだという点のみです。

ほぼ同じですがです。

function ReturnValue<T>(value: T) {
  return (
    prototypeObject: object,
    propertyName: string,
    descriptor: PropertyDescriptor
  ) => {
    descriptor.get = () => value;
    descriptor.set = () => {};

    Object.defineProperty(
      prototypeObject,
      propertyName,
      descriptor
    );
  };
}

interface ButtonComponentLandmark {
  foo: number;
}

class ButtonComponent {
  @ReturnValue<ButtonComponentLandmark>(
    {
      foo: 123
    }
  )
  get landmark(): ButtonComponentLandmark {
    return 'あとで' as any;
  }
}

console.log(
  new ButtonComponent().landmark
);

今回はゲッターに対しての適用なので、descriptor.valueではなくdescriptor.getを上書きしてます。

パラメーターデコレーター

パラメーターデコレーターはメソッドの引数それぞれに対して適用したデコレーターのことです。
パラメーターデコレーターは3つの引数を受け取り、プロパティデコレーターと同じように第1と第2にはそれぞれプロトタイプとなるオブジェクト、対象のキー名、第3引数にはパラメーターのシーケンス番号(何番目の引数?)になります。

メソッドデコレーターではObject#definePropertyなどで中身を上書きできましたが、パラメーターデコレーターではそれができません。もし上書きしたいならそれはメソッドデコレーターかアクセサデコレーターで行う必要があります。

やや複雑ですが、第3引数の値と Reflect API により「何番目の引数にどのデコレーターを実行したか」をメソッドデコレーターやアクセサデコレーターで取得することができるので、それらの情報から値を上書きすることはできます。

以下はパラメーター値を String にするです。

import 'reflect-metadata';

const StringMetadataKey = Symbol(
  'String'
);

function String(
  prototypeObject: object,
  propertyKey: string,
  parameterIndex: number
) {
  const expectedStringParametorIndexes =
    Reflect.getOwnMetadata(
      StringMetadataKey,
      prototypeObject,
      propertyKey
    ) || [];
  expectedStringParametorIndexes.push(
    parameterIndex
  );
  Reflect.defineMetadata(
    StringMetadataKey,
    expectedStringParametorIndexes,
    prototypeObject,
    propertyKey
  );
}

function Adjustment(
  prototypeObject: object,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const expectedStringParameterIndexes =
    Reflect.getOwnMetadata(
      StringMetadataKey,
      prototypeObject,
      propertyKey
    ) || [];

  const originalValue =
    descriptor.value;
  descriptor.value = function(
    ..._args: any[]
  ) {
    const args = _args.map((arg, i) => {
      if (
        expectedStringParameterIndexes.indexOf(
          i
        ) > -1
      ) {
        return arg.toString();
      }

      return arg;
    });

    return originalValue.apply(
      this,
      args
    );
  };
}

class ButtonComponent {
  @Adjustment
  setText(
    @String text1: string | number,
    number1: string | number,
    @String text2: string | number
  ) {
    console.log(this.a);
    return [text1, number1, text2];
  }
}

const result = new ButtonComponent().setText(
  123,
  123,
  123
);
console.log(typeof result[0]); // string
console.log(typeof result[1]); // number
console.log(typeof result[2]); // string

@Stringがパラメーターデコレーター、@Adjustmentがメソッドデコレーターです。

このパラメーターデコレーターを好きな位置のパラメーターの前に置くと、例えば「ButtonComponent#setTextメソッドのその位置のパラメーターは@Stringを適用して文字列にする」というように設定できます。その情報はReflect#defineMetadataメソッドを使いデコレーターキーとプロトタイプオブジェクト、対象キー名を目印に登録します。パラメーターの場合は同じデコレーターキーとプロトタイプオブジェクト、対象キー名で登録する機会が複数回あるかもしれない為、配列で保存します。

メソッドデコレーターでは、上書き用の関数で本来の関数をラップし、本来の関数が渡される予定の引数を一時的に受け取ります。これは...args: any[]のように配列で受け取ると回しやすく楽です。

引数の配列はArray#mapなどで繰り返し処理し、今の引数が@Stringを適用したパラメーターかどうかをパラメーターデコレーターで受け取ったparameterIndexの値を使って照合します。もしそうであれば、あらゆる値を文字列化する処理をした値を返し、そうでないならそのまま返します。
このような感じで新たな引数を作ります。

新たな引数はまだ配列なので本来の関数からFunction#applyメソッドを呼び出し渡します。これは渡した配列をそれぞれ別の引数として関数に渡して呼び出すメソッドです。
本来の関数に戻り値があることを踏まえ、上書き用の関数の戻り値は本来の関数の実行結果をreturnします。

そんなわけで、ButtonComponent#setTextメソッドを呼び出す時には既に上記のような感じで上書きされたsetTextとなっており、実行時に変換作業が行われた後に本来の内容が実行されるようになります。