仕事で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 |
数値 | コンストラクターのパラメーターデコレーター ※このときは propertyKey がundefined になる |
クラス 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限定としている。
}