ぼくのかんがえたさいきょうのDecorator

仕事でDecoratorを使うことになったので、Decoratorについて調査してみました。

どうせ目指すなら最強を。

※掲載しているサンプルコードはTypeScript4.4で確認しています。
　それより前のバージョンでは一部コンパイルでエラーになりますが、
　適宜バージョンに応じて書き替えてください。

## 最強の条件

- Decoratorが使えるところならどこでも使える。
- パラメーターを付けても、付けなくても使える。
- パラメーターが必要ないときは`()`を付けなくても使える。

こんな感じで指定できるようにします。

```ts
const propertySymbol = Symbol('property symbol');
const propertySymbol2 = Symbol('property symbol2');
const methodSymbol = Symbol('method symbol');

@decorator
@decorator()
@decorator(123)
@decorator('abc')
@decorator('abc', 123, true)
@decorator({t: 'test'})
class X {
  constructor(
    @decorator
    @decorator()
    @decorator(123)
    @decorator('abc')
    @decorator('abc', 123, true)
    @decorator({t: 'test'})
    parameter: string
  ) {
    throw 'not implement';
  }
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  property?: string;
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  get property2(): string {
    throw 'not implement';
  }
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  [propertySymbol]: string;
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  get [propertySymbol2](): string {
    throw 'not implement';
  }
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  method(
    @decorator
    @decorator()
    @decorator(123)
    @decorator('abc')
    @decorator('abc', 123, true)
    @decorator({t: 'test'})
    parameter: string
  ): string {
    throw 'not implement';
  }
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  [methodSymbol](
    @decorator
    @decorator()
    @decorator(123)
    @decorator('abc')
    @decorator('abc', 123, true)
    @decorator({t: 'test'})
    parameter: string
  ): string {
    throw 'not implement';
  }
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  static property: string;
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  static get property2(): string {
    throw 'not implement';
  }
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  static [propertySymbol]: string;
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  static get [propertySymbol2](): string {
    throw 'not implement';
  }
  @decorator
  @decorator()
  @decorator(123)
  @decorator('abc')
  @decorator('abc', 123, true)
  @decorator({t: 'test'})
  static method(
    @decorator
    @decorator()
    @decorator(123)
    @decorator('abc')
    @decorator('abc', 123, true)
    @decorator({t: 'test'})
    parameter: string
  ): string {
    throw 'not implement';
  }
  @decorator
  @decorator({t: 'test'})
  static [methodSymbol](
    @decorator
    @decorator()
    @decorator(123)
    @decorator('abc')
    @decorator('abc', 123, true)
    @decorator({t: 'test'})
    parameter: string
  ): string {
    throw 'not implement';
  }
}
```

## パラメーター無し

パラメーター無しのDecoratorはDecorator特有の引数と返値を取る必要があります。

Decoratorの型は以下のように定義されています。

```ts
declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
```

これをまとめて1つの関数にすると以下のようになります。返値の型がanyなのはそれぞれで返値の型が違うので仕方がないので諦めます。

```ts
function decorator(target: Object | Function, propertyKey?: string | symbol, desc?: PropertyDescriptor | number): any;
```

この関数であれば、Decoratorを使えるところであればどこでも書けるDecoratorになります。

## パラメーターあり

パラメーターありの場合は、Decoratorを返す関数にします。

返すDecoratorの型はパラメーター無しのときのDecoratorと同じ物です。

```ts
function decorator(...args: unknown[]): (target: Object | Function, propertyKey?: string | symbol, desc?: PropertyDescriptor | number) => any;
```

この関数であれば、パラメーター付きでどこでも書けるDecoratorになります。

またパラメーターを省略可能としている上に、先の関数とオーバーロードしているので、パラメーターを不要とする場合

```ts
  @decorator()
  @decorator
```

のどちらで書いても構いません。

パラメーターは`unknown`の配列にしているので、どんなパラメーターでも受け取れます。

## 型定義

何度も同じ記述をするのは面倒なので、型定義をしておきます。

```ts
type DecoratorParameters = [target: Object | Function, propertyKey?: string | symbol, desc?: PropertyDescriptor | number];
type Decorator = (...args: DecoratorParameters) => any;
```

この型を使ってさきほどの定義をまとめて書き直します。

```ts
function decorator(...args: DecoratorParameters | unknown[]): any {
  // 未実装
}
```

…これ、さらにまとめちゃってもいけそうですね。

```ts
function decorator(...args: unknown[]): any {
  // 未実装
}
```

とりあえずこれでvscode上ではエラーが表示されないようになりました。

## 実装

まずデコレーターとして呼ばれたか、デコレーターファクトリーとして呼ばれたかをパラメーターから判断します。

デコレーターの定義から引数の数が1～3でなければデコレーターではありません。

引数の数が1～3の場合は以下の組み合わせとデコレーターの種類になります。

|     第1引数<br>`target`      | 第2引数<br>`propertyKey` | 第3引数<br>`indexOrDesc` | デコレーターの種類                                                                        |
| :--------------------------: | :----------------------: | :----------------------- | :---------------------------------------------------------------------------------------- |
|            クラス            |       `undefined`        | `undefined`              | クラスデコレーター                                                                        |
|            クラス            |       `undefined`        | 数値                     | コンストラクターのパラメーターデコレーター<br>※このときは`propertyKey`が`undefined`になる |
| クラス<br>or<br>プロトタイプ | 文字列<br>or<br>シンボル | `undefined`              | プロパティデコレーター                                                                    |
| クラス<br>or<br>プロトタイプ | 文字列<br>or<br>シンボル | 数値                     | パラメーターデコレーター                                                                  |
| クラス<br>or<br>プロトタイプ | 文字列<br>or<br>シンボル | PropertyDescriptor       | メソッドデコレーター                                                                      |

これを元に型ガード関数を作ります。

```ts
// 3つの引数の種類からデコレーターの種類を判定する
const decoratorTypes = {
  class_constructor_property: 'class', // クラスデコレーター
  class_constructor_parameter: 'parameter', // コンストラクターのパラメーターデコレーター
  class_member_property: 'property', // staticなプロパティデコレーター
  class_member_parameter: 'parameter', // staticなメソッドのパラメーターデコレーター
  class_member_method: 'method', // staticなメソッドデコレーター
  prototype_member_property: 'property', // プロパティデコレーター
  prototype_member_parameter: 'parameter', // パラメーターデコレーター
  prototype_member_method: 'method', // メソッドデコレーター
} as const;

function isDecoratorParameters(args: unknown[]): args is DecoratorParameters {
  if (args.length < 1 || args.length > 3) {
    // 引数の数が合わないのでデコレーターではない
    return false;
  }
  const [target, propertyKey, indexOrDesc] = args;
  // それぞれの引数の種類
  const targetType = isClass(target)
    ? 'class'
    : isPrototype(target)
    ? 'prototype'
    : 'unknown';
  const propertyKeyType =
    propertyKey === undefined
      ? 'constructor'
      : typeof propertyKey === 'string' || typeof propertyKey === 'symbol'
      ? 'member'
      : 'unknown';
  const indexOrDescType =
    indexOrDesc === undefined
      ? 'property'
      : typeof indexOrDesc === 'number'
      ? 'parameter'
      : isPropertyDescriptor(indexOrDesc)
      ? 'method'
      : 'unknown';
  // 引数の種類を連結
  const decoratorTypeIndex = `${targetType}_${propertyKeyType}_${indexOrDescType}`;
  // 引数の種類の組み合わせと合致するデコレーターの種類が存在していればデコレーター
  return decoratorTypeIndex in decoratorTypes;
}
```

デコレーター/デコレーターファクトリーの両方を受け取れる関数を書き、引数がデコレーターのものであればデコレータを呼び出し、それ以外はデコレーターを返すようにします。

```ts
function decorator(...args: unknown[]): any {
  // Decoratorとしてのパラメーターか、DecoratorFactoryとしてのパラメータか
  const isDecorator = isDecoratorParameters(args);
  const parameters = isDecorator ? undefined : args;
  const impl: Decorator = (...args) => {
    // Decoratorとしての実装(例としてパラメータをログ出力する)
    console.log('decorator:', dump(args), 'parameters:', dump(parameters));
  };
  return isDecorator ? impl(...args) : impl;
}
```

これで実行可能なデコレーターになりました。

### 問題点

この実装の問題点はデコレーターファクトリーに渡すパラメーターとして、デコレーターと同じ型の物を渡すと判別に誤りが生じ、最悪実行時エラーになる、ということです。

たとえば、クラスを1つだけ渡すとクラスデコレーターのパラメーターと区別がつかず、デコレーターを返さないため、`import 'reflect-metadata';`していると実行時エラーになります。

```ts
class A {}

@decorator(A)
class B {}
```

vscode上ではエラーにならず、実行時にしかエラーとならないため、場合によっては致命的です。

### 解決策

実はこの点についてまっとうな解決策はまだ見つかっていません。

まっとうでない解決策としては、[Errorのstackプロパティ](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Error/Stack)からデコレーターの呼び出し元を取得して判別する、という方法があります。

デコレーターとして呼び出される場合には、途中に別の関数を挟む場合もありますが、`__decorate`という関数から呼び出されます。

なので`Error`を生成して`stack`プロパティに`__decorate`からの呼び出しが含まれているか確認することで、デコレーターとしての呼び出しかどうかを判別できます。

```ts
function isDecoratorParameters(args: unknown[]): args is DecoratorParameters {
  // デコレーターとして呼ばれたときにはスタックトレースに__decorateが含まれる
  return /^[ \t]*at[ \t]+__decorate[ \t]*\(/m.test(new Error().stack!);
}
```

こうすることで

```ts
class A {}

@decorator(A)
class B {}
```

と書いても実行時エラーにはならなくなります。

しかし、標準機能ではないのと`__decorate`というTypeScriptの内部実装に依存しているのとで、実際には使うべきではないでしょう。

まあ、そもそもどんなパラメーターでも受け付けるデコレーターなどというものがおかしいので、こんな実現方法もあり、ってぐらいで。

実際に使用されるデコレーターであればパラメーターに指定される内容は決まっているので、それぞれの型に合わせて実装すれば対応可能なはずです。

## おまけ

`isDecoratorParameters`の実装の中で出てきた、クラスかどうかを判別する関数`isClass`の実装は以下のようになっています。

```ts
function isClass(target: unknown): target is Class {
  return (
    // typeof Function.prototypeだけは'function'になってしまうので先に判定
    target === Function ||
    // target.prototype.constructorがtargetであればクラス
    (typeof target === 'function' &&
      typeof target.prototype === 'object' &&
      target.prototype.constructor === target)
  );
}
```

同様にプロトタイプかどうかを判定する関数`isPrototype`は以下のような実装になります。

```ts
function isPrototype(target: unknown): target is Object {
  return (
    // typeof Function.prototypeだけは'function'になってしまうので先に判定
    target === Function.prototype ||
    // target.constructor.prototypeがtargetであればプロトタイプ
    (typeof target === 'object' &&
      typeof target?.constructor === 'function' &&
      target.constructor.prototype === target)
  );
}
```

また、PropertyDescriptorかどうかを判別する`isPropertyDescriptor`はこうなっています。

```ts
function isPropertyDescriptor(o: unknown): o is PropertyDescriptor {
  return (
    // Objectリテラルでvalue/get/setのいずれかにfunctionが指定されていればPropertyDescriptorと見なす
    !!o && Object.getPrototypeOf(o) === Object.prototype &&
    (typeof (o as any).value === 'function' ||
      typeof (o as any).get === 'function' ||
      typeof (o as any).set === 'function')
  );
  // 実際にはPropertyDescriptorのvalueにはfunction以外も指定されていることがあるが
  // ここではメソッドデコレーターで使用されるPropertyDescriptorを判別しているため
  // function限定としている。
}
```