Typescript の Decorator
Typescript の Decorator

Typescript の Decorator

はじめに

この記事では Typescript の Decorator の挙動について基礎的な部分を解説します

動作の検証は以下のバージョンのツールで行ないました

$ tsc --version
Version 4.3.2

$ ts-node --version
v10.0.0

Decorator とは

Typescript の Decorator とは Class 内のメソッドやプロパティを修正したり変更したりできる関数の事です

公式にはここに説明があります

公式の説明によると、(2021年6月現在) Decorator は JavaScript の stage 2 proposal との事で、今後仕様に変更が入る可能性があります。またそのため、以下のように tsconfig で experimentalDecorators flag を true にしないと使えません

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

実行してみる

Decorator は概念だけだとかなり解りづらいと思うので実行してみるのが理解の近道だとおもいます

簡単なコードを走らせて挙動を確認してみます

class TestClass {

  @logError
  throwError(): void {
      throw new Error()
  }

}

function logError(target: any, key: string, desc: PropertyDescriptor): void {
  console.log("target", target)
  console.log("key", key)
  console.log("desc", desc)

  const method = desc.value

  desc.value = function() {
    try {
      method()
    } catch (e) {
      console.log("error catched")
    }
  }

}

new TestClass().throwError()
test.ts

上記コードを適当な名前で保存して (ここでは test.ts) tsc-node を使って実行してみます

$ ts-node test.ts 
target { throwError: [Function (anonymous)] }
key throwError
desc {
  value: [Function (anonymous)],
  writable: true,
  enumerable: true,
  configurable: true
}
error catched

どうやら TestClass の throwError メソッドが実行されて、その際に Decorator 関数内部で try catch に引っかかりコンソールログが出力されているようです

以下のコマンドで transpile して詳しくみてみます

$ tsc test.ts 
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var TestClass = /** @class */ (function () {
    function TestClass() {
    }
    TestClass.prototype.throwError = function () {
        throw new Error();
    };
    __decorate([
        logError
    ], TestClass.prototype, "throwError");
    return TestClass;
}());
function logError(target, key, desc) {
    console.log("target", target);
    console.log("key", key);
    console.log("desc", desc);
    var method = desc.value;
    desc.value = function () {
        try {
            method();
        }
        catch (e) {
            console.log("error catched");
        }
    };
}
new TestClass().throwError();
test.js

Decorator 関数の第一引数である target には TestClass class の prototype が入り、第二引数にはデコレートした関数名が入り呼び出されています。またコードを見ると Decorator はクラスが定義された瞬間に実行されています。Decorator はクラスインスタンスが実体化されるタイミングや、デコレートされたメソッドなどが実行されるタイミングで呼ばれるわけでは無い事がわかります

Decorator 関数の第三引数に入る desc は __decorate 関数の getOwnPropertyDescriptor で取得された PropertyDescriptor が入ります

desc = Object.getOwnPropertyDescriptor(target, key)

PropertyDescriptor は ES5 で定義された JS object の property を記述する構造体です

console log された結果を見ると以下のようなものになっており、value, writable, enumerable, configurable という 4 つの property を持つ事がわかります

desc {
  value: [Function (anonymous)],
  writable: true,
  enumerable: true,
  configurable: true
}

ここで、 value には decorate した関数そのものが入っています

getOwnPropertyDescriptor の挙動に関してはこちらで確認できます

改めて Decorator 関数が何を行なっているかを理解する

上記を踏まえて再度 Decorator 関数を見てみると、何を行なっているかが一目瞭然です

function logError(target: any, key: string, desc: PropertyDescriptor): void {
  console.log("target", target)
  console.log("key", key)
  console.log("desc", desc)

  const method = desc.value

  desc.value = function() {
    try {
      method()
    } catch (e) {
      console.log("error ctched")
    }
  }

}

実際に行なっている事は非常にシンプルで主に以下の 2 つです

  1. この関数はクラスの定義コードが走った際に 1 度だけ実行され
  2. 実行時に PropertyDescriptor を受け取り、関数を try catch で wrap した関数を作り、それを property にセットし直す

これだけで元々の関数を変更せずに Decorator 関数がセットされた関数が呼び出された際に exception が発生すると catch して error を console に出してくれるようにできます

Decorator Factory

上の例では logError Decorator が catch した際に console.log するメッセージは定数でした

例えばメソッドによってメッセージを変更する、などを行いたい場合には Decorator Factory を使う事で可能です

function color(value: string) {
  // this is the decorator factory, it sets up
  // the returned decorator function
  return function (target) {
    // this is the decorator
    // do something with 'target' and 'value'...
  };
}
公式から引用

例えば logError の例だと、以下のように wrap する事で Decorator 関数に設定値を受け取る事ができます (ここで受け取る値はクラスの定義実行時に 1 度だけで、関数の実行時に値を受け取るわけでは無い事に注意する必要があります)

function logError(message: string) {

	return function(target: any, key: string, desc: PropertyDescriptor): void {
	  console.log("target", target)
	  console.log("key", key)
	  console.log("desc", desc)

	  const method = desc.value

	  desc.value = function() {
	    try {
	      method()
	    } catch (e) {
	      console.log(message)
	    }
	  }

	}
}

Decorator Factory を使った logError

これを使う場合には以下のようになります

class TestClass {

  @logError("throwError でエラーになったよ")
  throwError(): void {
      throw new Error()
  }

  @logError("process でエラーになったよ. やばい!!")
  process(): void {
      console.log('普通の処理')
  }

}

Decorator の種類

ここまで Method Decorators をベースに挙動を説明してきましたが、Decorator には色々な種類があるので公式のコードを引用しつつ紹介します

Class Decorators

Class Decorators はクラスのコンストラクタに適用され、クラス定義への修正などを行います

Class Decorator は引数として constructor を受け取ります

function reportableClassDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    reportingURL = "http://www...";
  };
}

@reportableClassDecorator
class BugReport {
  type = "report";
  title: string;

  constructor(t: string) {
    this.title = t;
  }
}

const bug = new BugReport("Needs dark mode");
console.log(bug.title); // Prints "Needs dark mode"
console.log(bug.type); // Prints "report"

// // Note that the decorator _does not_ change the TypeScript type
// // and so the new property `reportingURL` is not known
// // to the type system:
console.log(bug.reportingURL);
公式から引用

Class Decorator で property を追加したとしても TypeScript の type system には認識してくれないので、 ts-node などで直接実行しようとするとエラーになるので注意が必要です

Method Decorators

Method Decorators は挙動の概要で説明したものです

引数として target key descriptor を受け取ります

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }

  @enumerable(false)
  greet() {
    return "Hello, " + this.greeting;
  }
}

function enumerable(value: boolean) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = value;
  };
}
公式から引用

descriptor の property を使ってメソッドの挙動を変更する事が可能です

Accessor Decorators

Method Decorator と同じ挙動をします

class Point {
  private _x: number;
  private _y: number;
  constructor(x: number, y: number) {
    this._x = x;
    this._y = y;
  }

  @configurable(false)
  get x() {
    return this._x;
  }

  @configurable(false)
  get y() {
    return this._y;
  }
}

function configurable(value: boolean) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.configurable = value;
  };
}
公式から引用

Property Decorators

property decorator は property に対して宣言します

Decorator 関数が受け取る引数は以下の 2 つになります

  • class の prototype (もしくは class の constructor)
  • member の名前

以下は

class Greeter {

  @logPropsCalled
  greeting: string;

  constructor(message: string) {
    this.greeting = message;
  }

}

function logPropsCalled(target: any, propertyKey: string): void {
  console.log(`${propertyKey} is called`)
}

console.log(new Greeter('message').greeting)
公式から引用をシンプルな形に修正

これを実行すると以下のようになります

$ ts-node greeter.ts 
greeting is called
message

Parameter Decorators

パラメーター宣言の直前で宣言します

下記公式の例では、Method Decorator と組み合わせて、引数のチェックを行なっています

具体的には required Decorator で必須となる parameter を宣言し、validate で必須となる引数が undefined じゃない事をチェックしています

class BugReport {
  type = "report";
  title: string;

  constructor(t: string) {
    this.title = t;
  }

  @validate
  print(@required verbose: boolean) {
    if (verbose) {
      return `type: ${this.type}\ntitle: ${this.title}`;
    } else {
     return this.title; 
    }
  }
}

import "reflect-metadata";
const requiredMetadataKey = Symbol("required");

function required(target: Object, propertyKey: string | symbol, parameterIndex: number) {
  let existingRequiredParameters: number[] = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyKey) || [];
  existingRequiredParameters.push(parameterIndex);
  Reflect.defineMetadata( requiredMetadataKey, existingRequiredParameters, target, propertyKey);
}

function validate(target: any, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) {
  let method = descriptor.value!;

  descriptor.value = function () {
    let requiredParameters: number[] = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyName);
    if (requiredParameters) {
      for (let parameterIndex of requiredParameters) {
        if (parameterIndex >= arguments.length || arguments[parameterIndex] === undefined) {
          throw new Error("Missing required argument.");
        }
      }
    }
    return method.apply(this, arguments);
  };
}
公式から引用

おわりに

Typescript の Decorator の基本について解説しました

Decorator は Experimental 機能でありながら数多くの Framework などでも採用されており、しっかりと理解しておいて損の無い機能だとおもいます