TypeScriptの新しいデコレータを使ってみる
TypeScriptの5.0で新しいデコレータに対応しました(ずいぶん前の話ですが・・・)
そろそろ使ってみるかということで、試してみたので、ここにまとめていきます。
デコレータとは
こういう記述を見たことはありませんか?
class Foo {
@bar // <-- これ
hoge() {
...
}
}
@bar
という記述で関数に対してなんかこう、色々便利にやってくれるものです。これはメソッドデコレータといって、メソッドの定義を差し替えたり、処理の前後に追加処理を入れてみたりすることができます。
いわゆる黒魔術とか言われる、裏で何かやってるけど詳しいことはよくわからない類の記述ですね。
新しいデコレータとは
そもそもTypeScriptには2種類のデコレータがあります。Legacy Decoratorと呼ばれるのが旧時代のデコレータで、TypeScript5で対応したのが新しいデコレータです。この記事では、デコレータと言えば新しいデコレータを指します。(古いデコレータにはあまり触れません)
詳しいことは調べていただけるとよいのですが、新しいデコレータはまだ仕様策定中のため、古いデコレータと比べるとできることは少ないです。なので、しばらくは古いデコレータを使うことになるでしょう。(ですが、今回は新しいデコレータの使い方を紹介します)
デコレータの種類
現在、デコレータを適用できる対象は、クラス、クラスメソッド、クラスフィールド、クラスアクセサ(ゲッター、セッター)です。
それぞれ、どうやって使うか、何ができるかをみていきましょう。
クラスデコレータ
クラスデコレータは以下のようにして使います。使いどころとしては、クラスの定義そのものを改変したりするときなどがよいでしょう。例えば、以下の例ではclassDecorator
を適用すると、自動的にhoge
メソッドが生えます。
//
@classDecorator
class Foo {
...
}
const foo = new Foo();
console.log(foo.hoge()); // It works.
上記のデコレータを実際に定義してみましょう。こうです。
const classDecorator = (clazz) => {
return class ExtendedClass extends clazz {
hoge() {
return "It works.";
}
}
}
↑上記のように実装することでクラスを継承した新しいクラスで上書きすることができます。TypeScript(JavaScript)では、クラスは好きなところで定義することができるので、このようなことができています。
クラスデコレータの型を書くと、こんな感じになるでしょうか。
type Constructor = {
new(...args: any[]): any;
}
type ClassDecorator = <T extends Constructor>(clazz: T) => T;
クラスデコレータのつかいどころ
昔、ReactにHOCという概念があったと思いますが、そういうときに使うのがよいのではないでしょうか(今更ですが)。
メソッドデコレータ
クラスメソッドを改変することができます。例えば、関数呼び出しの前後でログを吐くデコレータです。
class Foo {
@logging
method(arg1: string, arg2: number): boolean {
console.log(`${arg1} ${arg2}`);
return true;
}
}
const foo = new Foo();
foo.method("hoge", 42);
// 以下のようなログが出る
// args is "hoge", "42"
// hoge 42
// result is "true"
このデコレータを実際に実装するとこうなります。
const logging = (target, context) => {
return (this, ...args) => {
console.log(`args is ${args.map(arg => `"${arg}"`).join(", ")}`);
const result = target.call(this, ...args);
console.log(`result is "${result}"`);
}
}
↑logging
はデコレータです。target
はデコレータを適用した関数で、context
はメソッドに関する情報が入っています。context.name
にはメソッド名が入っていたりします。
デコレータの戻り値として、新しい関数を返しています。Foo#method
を新しい関数で上書きする感じです。やろうと思えば元の実装を無くして完全に新しい動作をさせることだって可能です。
今回は、関数呼び出しの前後で引数と戻り値をログに出力する、という実装をしています。
メソッドデコレータの型定義は以下のようになるでしょう。
type ClassMethodDecorator = <This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
) => (this: This, ...args: Args) => Return;
const logging: ClassMethodDecorator = ...
フィールドデコレータ
クラスフィールドの初期化時に、値を改変することができます。以下の例は、常に初期値を2倍するデコレータです。
class Foo {
@twice
bar = 42;
}
const foo = new Foo();
console.log(foo.bar) // 84
このデコレータは以下のような実装になります。
const twice = (target, context) => {
return (initialValue) => {
return initialValue * 2;
}
}
↑関数twice
はデコレータです。このデコレータはクラスフィールドを初期化する関数を返します。初期化関数は、クラスフィールドが初期化されるとき(コンストラクターが呼ばれたとき)に実行されます。
初期化関数が返した値が、実際にフィールドに適用される値になります。こちらも、やろうと思えば、ユーザーが設定した初期値を完全に無視して好きな値をセットすることもできます(が、バグの温床になるので、やらない方がよいでしょう)。
なお、target
は常にundefined
になります。このデコレータが実行されるタイミングでは、まだフィールドは初期化されていないから、というのが理由のようです。じゃあ、なんでこんな関数定義にしたんだ、というと、おそらくですが、全部同じシグネチャにしたかったからだろうなーと思います。
また、フィールドデコレータの型定義は次のようになります。
type PropertyInitializer<T> = (val: T) => T
type PropertyDecorator<This = any, Value = any> = (target: undefined, context: ClassFieldDecoratorContext<This, Value>) => (void | PropertyInitializer<Value>);
const twice: PropertyDecorator = ...
応用編
上記の説明だけでは、黒魔術を実現するにはまだ足りない部分があるので、そのあたりをいくつか書きます。
フィールドデコレータやメソッドデコレータでクラスインスタンスにアクセスしたいんじゃが
あまり褒められたものではありませんが、フィールドデコレータやメソッドデコレータでクラスインスタンスにアクセスしたいことがあるでしょう。黒魔術を実現するのですから、必要な場合もあります。
たとえば、フィールドアクセス時に、ログを出したい場合、どうすればよいでしょうか。私の知る限りだと、クラスインスタンスにアクセスする必要があります。
class Foo {
@fieldAccessLogging
field: number = 42;
}
const foo = new Foo();
console.log(foo.field);
foo.field = 43;
// 以下のログが出るとする
// Field "foo" accessed!
// 42
// Field "foo" set new value: "43"
デバッグ時に役立ちそうですね。では、fieldAccessLogging
デコレータを実装していきましょう。
const fieldAccessLogging: PropertyDecorator = (_, context) => {
context.addInitializer(function() {
let value = context.access.get(this);
Object.defineProperty(this, context.name, {
get() {
console.log(`Field "${context.name.toString()}" accessed!`);
return value;
},
set(val) {
console.log(`Field "${context.name.toString()}" set new value: "${val}"`);
value = val;
},
});
});
}
↑こちらがフィールドアクセス時にログを吐いてくれるデコレータです。context.addInitializer
という関数に、フィールドを初期化する関数(ArrowFunctionでないことに注意)を渡しています。
初期化関数の中では、this
でクラスインスタンスにアクセスできます(だからfunctionでなければならない)。
もともとの説明では、デコレータの戻り値で初期化関数を返す、という話をしました。そちらでも同じことができるので、好きな方を使っていただければと思いますが、実行のタイミングが異なるため、目的に応じた使い方をするとよいでしょう。
複数のデコレータを適用したいんじゃが
できます。複数のデコレータを適用するとより内側のデコレータが先に適用されます。以下の例だと、deco2
が適用され、その結果がdeco1
に渡され、その結果がmethod
の最終的な状態になります。適用するものと順序によってはぶっ壊れる可能性もあるということに注意するとよいでしょう。
class Foo {
@deco1
@deco2
method() {
...
}
}
デコレータに変数を渡したいんじゃが
できます。デコレータを生成するファクトリー関数を定義しましょう。以下のように使います。例として、デコレータに渡した数字を足して初期化してくれるものを考えます。
class Foo {
@add(13)
bar = 42
}
const foo = new Foo();
console.log(foo.bar); // 55
このデコレータは以下のように実装できます。
const add = (number: number): PropertyDecorator<any, number> => {
return (_, context) => {
return (initialValue) => {
return initialValue + number;
}
}
}
↑ちょっとネストが激しくて若干見づらいですが、add
はPropertyDecoratorを返してくれるファクトリー関数です。デコレータの実体は、initialValue
を受け取って、add
が受け取った引数を足し算したものをフィールドの初期値として設定しているだけです。
その他
デコレータで上記に説明した以外に知っておくとよいかもしれない知識です。必要に応じて調べてみてください。
- メタデータ
- デコレータで操作する対象にメタデータを付与できる機能。
context.metadata
でアクセスできる。複数のデコレータが協力して何かをするときなどに使えそう。
- デコレータで操作する対象にメタデータを付与できる機能。
- private, staticなメンバーかどうかを知る
-
context.private
やcontext.static
で取得できる。
-
まとめ
すみません、クラスアクセサデコレータは面倒だったので説明を省きました。
今後は新しいデコレータが主流になっていくと思うので(いつになるんでしょう・・・)、先に学んでおくとよいでしょう。
以上です、よろしくお願いします。
Discussion