🎍

TypeScriptの新しいデコレータを使ってみる

2024/09/21に公開

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.privatecontext.staticで取得できる。

まとめ

すみません、クラスアクセサデコレータは面倒だったので説明を省きました。
今後は新しいデコレータが主流になっていくと思うので(いつになるんでしょう・・・)、先に学んでおくとよいでしょう。

以上です、よろしくお願いします。

Discussion