Stage 3 Decorators のことを思い出す時に読む記事

デコレータの Proposal が Stage 3 になってから約 4 年, TypeScript がサポートしてから約 3 年経っているにも関わらず, 未だに普段使いしなさすぎて全く使い方を覚えられていません. ということで TypeScript での使い方を中心に覚える / 忘れたら読んで思い出すために記事を書いておきます.

なお具体的なユースケースについてはほぼ触れませんので悪しからず. それについてはまたの機会に...

仕様の本体 = ECMAScript への Proposal は以下.

TypeScript のリリースノートは以下.

Stage 3 に至るまでの変遷については以下が詳しいです.

デコレータ

デコレータを付与できる対象は,

  • クラス
  • メソッド
  • getter
  • setter
  • フィールド
  • auto-accessor

の 6 種類です.

@classDecorator
class MyClass {
  @methodDecorator
  myMethod(): void {
    // ...
  }

  @getterDecorator
  get myValue(): number {
    return 0;
  }

  @setterDecorator
  set myValue(value: number) {
    // ...
  }

  @fieldDecorator
  myField: number = 0;

  @accessorDecorator
  accessor myAccessor: number = 0;
}

private (TypeScript の private 修飾子ではなく # をつけたやつのこと) であったり static なメソッドやフィールドに対しても付与できます.

コンストラクタに対するデコレータや, クラスではない単なる関数に対するデコレータはありません. まあ前者はそもそもクラス自体と同じものですし, 後者はだいたい高階関数で十分そうですね. コンストラクタやメソッドのパラメータに対するデコレータは TypeScript の experimental decorators にはありましたが, Stage 3 では削除されています.

Auto-accessor

デコレータと同じ Proposal では, auto-accessor という新しい種類のフィールドも提案されています. これはフィールドの前に accessor 修飾子を付与することで定義できて, 例えば,

class MyClass {
  accessor myAccessor: number = 0;
}

というコードは,

class MyClass {
  #myAccessor: number = 0;

  get myAccessor(): number {
    return this.#myAccessor;
  }

  set myAccessor(value: number) {
    this.#myAccessor = value;
  }
}

と概ね同等です (実際には #myAccessor が private なフィールドとして見えることもありません).

通常のフィールドに対するデコレータでは get や set をフックすることができませんが, auto-accessor に対するデコレータではこれらをフックできます.

TypeScript での型

それぞれ最も汎用的な (と思われる) ものを示します.

function classDecorator<Class extends abstract new (...args: any) => any>(
  target: Class,
  context: ClassDecoratorContext<Class>,
): Class | void {
  // ...
}

function methodDecorator<This, Args extends any[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
): ((this: This, ...args: Args) => Return) | void {
  // ...
}

function getterDecorator<This, Value>(
  target: (this: This) => Value,
  context: ClassGetterDecoratorContext<This, Value>,
): ((this: This) => Value) | void {
  // ...
}

function setterDecorator<This, Value>(
  target: (this: This, value: Value) => void,
  context: ClassSetterDecoratorContext<This, Value>,
): ((this: This, value: Value) => void) | void {
  // ...
}

function fieldDecorator<This, Value>(
  target: undefined,
  context: ClassFieldDecoratorContext<This, Value>,
): ((this: This, initialValue: Value) => Value) | void {
  // ...
}

function accessorDecorator<This, Value>(
  target: ClassAccessorDecoratorTarget<This, Value>,
  context: ClassAccessorDecoratorContext<This, Value>,
): ClassAccessorDecoratorResult<This, Value> | void {
  // ..
}

コンテキストオブジェクト context にはデコレータを付与した対象の情報や, 各種操作を行うための関数が入っています. ClassFieldDecoratorContext を抜粋しつつ紹介するとこういう感じ.

interface ClassFieldDecoratorContext<This = unknown, Value = unknown> {
  // 対象の種類
  readonly kind: "field";
  // 対象の名前
  readonly name: string | symbol;
  // (クラス以外) 対象が static かどうか
  readonly static: boolean;
  // (クラス以外) 対象が private かどうか
  readonly private: boolean;
  // (クラス以外) 対象へアクセスするための関数群
  readonly access: {
    has(object: This): boolean;
    get(object: This): Value;
    set(object: This, value: Value): void;
  };
  // 対象が属するクラスに初期化ロジックを追加するための関数
  addInitializer(initializer: (this: This) => void): void;
  //  対象が属するクラスのメタデータオブジェクト
  readonly metadata: DecoratorMetadata;
}

Auto-accessor に対するデコレータについての ClassAccessorDecoratorTargetClassAccessorDecoratorResult の定義は以下のようになっていて, ちょうど getter / setter とフィールドに対してのものを合わせた形をしています.

interface ClassAccessorDecoratorTarget<This, Value> {
  get(this: This): Value;
  set(this: This, value: Value): void;
}

interface ClassAccessorDecoratorResult<This, Value> {
  get?(this: This): Value;
  set?(this: This, value: Value): void;
  init?(this: This, value: Value): Value;
}

できること

対象の置き換え

クラス, メソッド, getter, setter に対するデコレータでは, デコレータから戻り値を返すことで, 対象をその値で置き換えられます.

フィールドに対するデコレータでは, デコレータから関数を返すことで, フィールドの初期値を置き換えられます. 例えば以下のような感じ.

function doubled(_target: undefined): (initialValue: number) => number {
  return (initialValue) => 2 * initialValue;
}

class MyClass {
  @doubled
  myField: number = 3;
}

const obj = new MyClass();
console.log(obj.myField);
// => 6

メソッドなどの場合とは異なりデコレータから直接置き換え後の値を返すようになっていないのは, フィールドの初期値がインスタンス化のタイミングで評価されるので, デコレータ自体の評価タイミング (クラス定義時) とは異なるからですね.

ところで TypeScript では, 以下のようにフィールドをその場で初期化しなくても, コンストラクタ内でフィールドが初期化されていればコンパイルエラーになりません.

class MyClass {
  @doubled
  myField: number;

  constructor() {
    this.myField = 3;
  }
}

一方でこの場合, デコレータから返した関数の引数 initialValue には (型の上では number であるにも関わらず) undefined が入ってきます. 意図せずランタイムエラーにならないよう注意しましょう.

Auto-accessor の場合は, getter / setter の置き換えと初期値の置き換えがそれぞれ行えます.

いずれの場合も, TypeScript においては型の変更が許されていません.

対象へのアクセス

クラスを対象とするデコレータ以外については, コンテキストオブジェクトに含まれる access を使うと, 最終的な (どこかのデコレータで値を置き換えていれば置き換え後の) 対象にアクセスできます. 一見すると同じくコンテキストオブジェクトに含まれる name を使ってもアクセスできそうなものではあるんですが, 対象が private な場合はそうもいかないので, 統一的なアクセスの手段として access が提供されているというわけです.

やや人工的な例ですが, 以下のように対象へのアクセス権をどこかに渡すようなケースで使えます.

const getters = new Map<string | symbol, (obj: unknown) => unknown>();

function exposeGetter(_target: undefined, context: ClassFieldDecoratorContext): void {
  getters.set(context.name, context.access.get);
}

class MyClass {
  @exposeGetter
  publicField: number = 0;
  @exposeGetter
  #privateField: number = 1;
}

const obj = new MyClass();
console.log(getters.get("publicField")?.(obj));
// => 0
console.log(getters.get("#privateField")?.(obj));
// => 1

初期化ロジックの追加

コンテキストオブジェクトに含まれる addInitializer を使うと, クラスやインスタンスの初期化に合わせて実行されるロジックを追加できます.

Proposal ではカスタム要素を定義する例が紹介されていました. これをデコレータでやる必要があるかはさておき...

function customElement<Class extends new (...args: any) => HTMLElement>(name: string) {
  return (_target: Class, context: ClassDecoratorContext<Class>) => {
    context.addInitializer(function (this) {
      customElements.define(name, this);
    });
  };
}

@customElement("my-element")
class MyElement extends HTMLElement {
  // ...
}

メタデータの付与

コンテキストオブジェクトに含まれる metadata を使うと, 対象が属しているクラス (対象がクラスの場合はそのもの) に対してメタデータを付与できます.

例えば以下のようにすると, デコレータを付与したフィールドの名前 (キー) をクラスのメタデータに保存しておいて, あとから列挙できます. Object.keys などでもオブジェクトのキーは列挙できますが, これは意図しないものも含む可能性があるので, あらかじめ決まったキーだけ列挙したいというような場合はデコレータを使うのが便利かもしれません.

const keysKey = Symbol("keys");
function key(_target: undefined, context: ClassFieldDecoratorContext): void {
  if (context.static || context.private) {
    throw new Error("cannot register static or private keys");
  }
  const keys = (context.metadata[keysKey] as Set<string | symbol> | undefined) ?? new Set();
  context.metadata[keysKey] = keys;
  keys.add(context.name);
}

class MyClass {
  @key foo: number = 0;
  @key bar: number = 1;
  baz: number = 2;
}

console.log(MyClass[Symbol.metadata]?.[keysKey]);
// => Set(2) { 'foo', 'bar' }

なお TypeScript でトランスパイルしたコードでは, Symbol.metadata が定義されていない場合はコンテキストオブジェクトに metadata が含まれません. 大抵の環境ではまだ Symbol.metadata が未定義かと思うので, ひとまずメタデータを使う場合は以下のように polyfill しておきましょう.

// @ts-expect-error
Symbol.metadata ??= Symbol("Symbol.metadata");

ファクトリ関数

addInitializer の説明の際にしれっと登場していましたが, デコレータを作るファクトリ関数を定義することも可能です. 例えばメソッドに対するデコレータのファクトリ関数の場合は以下のような感じになります.

function factory<This, Args extends any[], Return>() {
  return (
    target: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
  ): ((this: This, ...args: Args) => Return) | void => {
    // ...
  };
}

上の例では型引数をファクトリ関数の側に書いていますが, この場合も型引数がよしなに推論されます.

class MyClass {
  // This = MyClass, Args = [], Return = void が推論される
  @factory()
  myMethod(): void {
    // ...
  }
}

参考: TypeScript の Experimental Decorators

比較用に TypeScript の experimental decorators についても簡単にまとめておきます.

ドキュメントは以下.

Experimental decorators を使う場合はコンパイラオプションで experimentalDecorators を有効にする必要があります. なお Stage 3 のデコレータとの共存はできません.

デコレータ

デコレータが付与できる対象は,

  • クラス
  • メソッド
  • getter / setter の組
  • プロパティ (フィールド)
  • コンストラクタやメソッドのパラメータ

の 5 種類です.

@classDecorator
class MyClass {
  @methodDecorator
  myMethod(): void {
    // ...
  }

  @accessorDecorator
  get myValue(): number {
    return 0;
  }
  set myValue(value: number) {
    // ...
  }

  @propertyDecorator
  myProperty: number = 0;

  constructor(@parameterDecorator myParam: number) {
    // ...
  }
}

static なメソッドやフィールドに対しても付与できますが, private (#) なメソッドやフィールドには付与できません (experimental decorators の仕様が決まった時点では存在しなかったので非対応のまま). また getter / setter に対してそれぞれに異なるデコレータを付与することはできません.

TypeScript での型

それぞれ最も汎用的な (と思われる) ものを示します.

function classDecorator<Target extends abstract new (...args: any) => any>(
  target: Target,
): Target | void {
  // ...
}

function methodDecorator<Target, Args extends any[], Return>(
  target: Target,
  key: string | symbol,
  descriptor: TypedPropertyDescriptor<(this: Target, ...args: Args) => Return>,
): TypedPropertyDescriptor<(this: Target, ...args: Args) => Return> | void {
  // ...
}

function accessorDecorator<Target, Value>(
  target: Target,
  key: string | symbol,
  descriptor: TypedPropertyDescriptor<Value>,
): TypedPropertyDescriptor<Value> | void {
  // ...
}

function propertyDecorator<Target>(target: Target, key: string | symbol): void {
  // ...
}

function parameterDecorator<Target>(
  target: Target,
  methodKey: string | symbol | undefined,
  index: number,
): void {
  // ...
}

見てわかる Stage 3 との違いとしては,

  • それぞれの第一引数 target は, 対象がクラスの場合を除いてデコレータを付与した対象そのものではない
    • static な場合はクラス自体, そうでない場合はクラスの prototype です
  • メソッドや getter / setter については, 対象の値だけではなく property descriptor 全体を扱う
  • コンテキストオブジェクトはない

あたりでしょうか.

できること

対象の置き換え

Stage 3 と同様に, デコレータから戻り値を返すことで対象を置き換えられます. ただしプロパティの初期値の置き換えには対応していないのと, パラメータについても置き換えのような機能はありません.

メタデータの付与

Experimental decorators の仕様上にはメタデータを付与するための仕組みはありませんが, reflect-metadata を使うことでクラスに対してメタデータを付与できます.

またコンパイラオプションで emitDecoratorMetadata を有効にすると, メソッドや getter / setter, プロパティに対して,

  • design:type
  • design:paramtypes
  • design:returntype

といったコンパイル時の型情報を表すメタデータが自動で付与されます.

まとめ?

特にまとめとかはないんですが, なんかデコレータの面白い || 便利な使い方があったら教えてください.