TypeScriptのDecoratorsについて調べた

はじめに

ライブラリ等でよくみるTypeScriptのDecoratorsについて調べてみました。有名なライブラリではTypeORMもDecoratorsを利用しています。(TypeORMはTypeScriptで書かれたORMapperライブラリ) Decoratorsは一見するとJavaのアノテーションのようなものです。 TypeORMのEntityの定義の例は以下です。

import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";

@Entity()
export class User {

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    firstName: string;

    @Column()
    lastName: string;

    @Column()
    age: number;

}

クラスやフィールドに@~~がついています。 これがDecoratorsです。Decoratorsの仕様自体は現在(2019年9月時点)プロポーザルの段階(ステージ2)です。

proposal-decorators

TypeScriptではtsconfigの設定値を変更することによって利用できます。 TypeScript の Decorators についてのドキュメントは以下にあります。

Decorators

詳しくは上記のドキュメントを参照してください。

今回はどのようにTypeScriptのDecoratorsを実装するのかを調べました。

tsconfig.json

公式ドキュメントにも書いてありますが、Decoratorsの機能を利用する場合はcompilerOptionstargetをES5以上にし、experimentalDecoratorstrueにする必要があります。

{
    "compilerOptions": {
        "target": "ES5",
        "experimentalDecorators": true
    }
}

Decoratorsの種類

Decoratorsには種類があります。指定された場所に@関数名を書くことによって特定のDecoratorsとして見なされます。 Decoratorsの種類は以下の5つです。

Decoratorsの実態は関数です。 @関数名でその関数が適用されます。 Decoratorsを定義する関数には自動的に引数に値が入ります。

Decoratorsの実装

以下のサンプルのコードを利用して、Decoratorsの実装について考えます。 サンプルで扱うDecoratorsはClass Decorators,Method Decorators,Property Decoratorsの3つです。

サンプルコードはGitHubにあります。

サンプルコード:

console.log("start of class definition\n");
@classDecorators
class Adding {
  @propertyDecorators
  baseNumber: number;
  
  constructor(baseNumber: number) {
    this.baseNumber = baseNumber;
  }

  @multiply(2)
  add(plus: number) {
    return (this.baseNumber += plus);
  }
}
console.log("end of class definition\n");

function classDecorators(constructor: Function) {
  console.log("class\n");
}

function propertyDecorators(target: Object, propertyKey: string) {
  console.log("property\n");
}

function multiply(num: number) {
  return (
    target: Object,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) => {
    console.log("method\n");
    const addFunc = descriptor.value;
    descriptor.value = function(...args: any) {
      const result = addFunc.apply(this, args);
      return result * num;
    };
  };
}

console.log("before initialize class\n");
const adding = new Adding(1);
console.log("after initialize class\n");
console.log(adding.add(1));
adding.baseNumber = 3;
console.log(adding.add(1));

上記の例では足し算を表すAddingクラスに対してDecoratorsを適用しました。 AddingクラスにはbaseNumberという足し算の元になる数(足される数)を表すプロパティとaddという足し算を表すメソッドがあります。

Class Decorators

Class Decoratorsはクラス宣言の上に@関数名を記述します。 今回の例ではAddingクラス定義の上に@classDecoratorsと記述されているのがわかります。 この記述によって呼び出される関数がclassDecorators関数です。 classDecorators関数はは”class”と標準出力する単純なものです。

function classDecorators(constructor: Function) {
  console.log("class\n");
}
Class Decoratorsの関数の引数にはクラスのコンストラクタが渡されます。 (今回は引数で渡ってくるコンストラクタに対して全く何もしていません。)

class Decoratorsでは値を返すことができ、値を返した場合は、クラスのコンストラクタ関数がその値で置き換えられます。

Propterty Decorators

Property Decoratorsはクラスのプロパティの上に@関数名を記述します。 今回はの例ではbaseNumberの上に@propertyDecoratorsと記述されているのがわかります。 この記述によって呼び出される関数がpropertyDecorators関数です。 propertyDecorators関数は”property”と標準出力する単純なものです。

function propertyDecorators(target: Object, propertyKey: string) {
  console.log("property\n");
}
Property Decoratorsの関数の引数には以下の2つが渡されます。

Property Decoratorsでは値を返すことはできません。

Decorator Factory

Method Decoratorsの前にDecorator Factoryについて説明します。(サンプルコードのMethod DecoratorsでDecorator Factoryを利用しているため。) Decorator FactoryはDecoratorsをカスタマイズしたい場合に利用します。 Decorator Factoryを利用することによってDecoratorsに対するオプションの引数を受け取ることが可能になります。 Decorator FactoryはDecoratorsの関数を返します。

function color(value: string) { // this is the decorator factory
    return function (target) { // this is the decorator
        // do something with 'target' and 'value'...
    }
}
こうすることによって@color(“hoge”)と記述でき、Decoratorsの中で、”hoge”を利用することができます。

Method Decorators

Property Decoratorsはクラスのメソッドの上に@関数名を記述します。 今回の例ではaddメソッドの上に@multiplyと記述されています。 この記述によって呼び出されるのがmultiply関数です。 multiply関数は引数で渡ってきた数字をaddメソッドの結果にかける(かけ算する)という関数で、addメソッドを上書きしています。 今回のmultiply関数ではDecorator Factoryを利用しています。

function multiply(num: number) {
  return (
    target: Object,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) => {
    console.log("method\n");
    const addFunc = descriptor.value;
    descriptor.value = function(...args: any[]) {
      const result = addFunc.apply(this, args);
      return result * num;
    };
  };
}

Method Decoratorsの関数の引数には以下の3つが渡されます。

もしかするとプロパティディスクリプタはあまり馴染みがないかもしれません。 もし、初めて聞いたという場合は以下のページのプロパティディスクリプタの説明を参照してみてください。

Object.getOwnPropertyDescriptor() - JavaScript | MDN

今回はプロパティディスクリプタのvalue属性を利用しています。 descriptor.valueにはメンバの値が入っています。 なので、今回の場合はdescriptor.valueでadd関数が取得できます。

const addFunc = descriptor.value;
addFuncにはadd関数が入っています。
    descriptor.value = function(...args: any[]) {
      const result = addFunc.apply(this, args);
      return result * num;
    };
さらに、descriptor.valueを関数で上書きすることによって、その関数でadd関数を上書きしています。 function(..args: any[])としているのはaddFunc.apply(this, args)で元のadd関数を実行したいからです。

applyについては以下を参照してください。

Function.prototype.apply() - JavaScript | MDN

今回は実行時のthisにクラスのオブジェクトをバインドしたいために、applyを利用しました。 thisをバインドしないと、add関数を実施した際に、this.baseNumberの部分でエラーが発生します。

function(...args: any[])には実際にはnumber型の値が一つはいるだけです。 これもapply関数の第二引数が配列しか受け取らないためこうしています。(function(…args: any[])はよく見る書き方、applyと一緒に覚えると良さそうです。)

最後にresult * numaddFunc.apply(this, args)no 実行結果に@multiply(num)で受け取ったnumをかけています。

サンプルコードを実行した結果

tscコマンドでコンパイルした後、nodeで実行しました。 結界は以下のようになりました。

 node dist/index.js
start of class definition

property

method

class

end of class definition

before initialize class

after initialize class

---------------------------------

4
8
無事、property, method, classが出力され、 add関数の結果に対して2がかけられていることがわかります。

4 = (1 + 1) * 2
8 = (3 + 1) * 2 

この結果からクラス定義の時点でDescriptorsが読み込まれていることがわかります。

おわりに

TypeScriptのDecoratorsについて調べてみました。 Decoratorsについて学ぶ時ドキュメントを見るだけでなく実際に使われているライブラリの実装をみてみるのも勉強になります。 いきなりTypeORMの実装を追うのはかなり大変なので、小さいライブラリから実装を見るのがお勧めです。 以下のtypescript-memoizeは小さくてお勧めです。

(メソッドをメモ化してくれるライブラリ) darrylhodgins/typescript-memoize

今後も継続してTypeScriptについて勉強していきたいと思います。