0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

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

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

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

最強の条件

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

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

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の型は以下のように定義されています。

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なのはそれぞれで返値の型が違うので仕方がないので諦めます。

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

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

パラメーターあり

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

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

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

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

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

  @decorator()
  @decorator

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

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

型定義

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

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

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

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

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

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

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

実装

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

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

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

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

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

// 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;
}

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

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';していると実行時エラーになります。

class A {}

@decorator(A)
class B {}

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

解決策

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

まっとうでない解決策としては、Errorのstackプロパティからデコレーターの呼び出し元を取得して判別する、という方法があります。

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

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

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

こうすることで

class A {}

@decorator(A)
class B {}

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

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

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

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

おまけ

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

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は以下のような実装になります。

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はこうなっています。

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限定としている。
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?