blog.euxn.me

TypeScript の Decorator と継承

2019-12-18 Wed.

この記事は NestJS アドベントカレンダー 2019 14 日目の記事です。 寝込んでいたため遅くなり申し訳ありません。

はじめに

この記事では NestJS で多用される Decorator を継承した場合の挙動について説明します。 サンプルコードのリポジトリは以下になります。

https://github.com/nestjs-jp/advent-calendar-2019/tree/master/day14-decorator-and-inheritance

なお、環境は執筆時点での Node.js の LTS である v12.13.x を前提とします。 また、この Decorator の挙動は ECMA Script 仕様として定義されていない Decorator に対して、TypeScript 3.7.x 時点での実装による挙動であるため、将来的に仕様の策定・変更に伴い TypeScript コンパイラの挙動が変更になる可能性があります。

結論

メソッドの Decorator 情報は継承されます。オーバーライドで切ることができます。

プロパティの Decorator は Class の定義時にしか評価されません。 しかし評価時にクラス名をキーにして container に副作用を与え、 instanceof で比較を行うようなライブラリでは、 instanceof は子 Class に対して親 Class と比較しても true となる(後述します)ため、継承しているような挙動に見えることがあります。

詳しくは以下で、 Method Decorator と Property Decorator に分けて説明します。

Method Decorator の挙動を追う

Decorator を定義した Class を継承した、 Decorator を直接定義していない Class のインスタンスを生成し、 Validator を定義した sayHello() を呼びます。 以下で定義する @LogProxy() は、関数の実行前後にログを出力する簡単な Decorator 関数です。

src/main.ts
1function LogProxy(when: 'before' | 'after' | 'all') {
2 return function(_target: any, key: string, desc: PropertyDescriptor) {
3 const prev = desc.value;
4 const next = function() {
5 if (when === 'before' || when === 'all') {
6 console.log(`${this.name}.${key} will start.`);
7 }
8 const result = prev.apply(this);
9 if (when === 'after' || when === 'all') {
10 console.log(`${this.name}.${key} has finished.`);
11 }
12 return result;
13 };
14 desc.value = next;
15 };
16}
17
18class User {
19 name: string;
20
21 constructor(name: string) {
22 this.name = name;
23 }
24
25 @LogProxy('all')
26 sayHello() {
27 console.log(`Hello, I am ${this.name}.`);
28 }
29}
30
31class JapaneseUser extends User {
32 name: string;
33
34 constructor(name: string) {
35 super(name);
36 this.name = name;
37 }
38}
39
40const alice = new User('alice');
41alice.sayHello();
42const arisu = new JapaneseUser('有栖');
43arisu.sayHello();
1$ yarn ts-node src/main.ts
2
3alice.sayHello will start.
4Hello, I am alice.
5alice.sayHello has finished.
6有栖.sayHello will start.
7Hello, I am 有栖.
8有栖.sayHello has finished.

コンパイルされた Decorator がどのような挙動をしているのか確認するため、コンパイルされたファイルを読みます。なお、 target は es2019 ですが、 2015 以降であれば Decorator 周りはほぼ変わらないようです。

dist/main.js
1var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2 var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3 if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4 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;
5 return c > 3 && r && Object.defineProperty(target, key, r), r;
6};
7function LogProxy(when) {
8 return function (_target, key, desc) {
9 const prev = desc.value;
10 const next = function () {
11 if (when === 'before' || when === 'all') {
12 console.log(`${this.name}.${key} will start.`);
13 }
14 const result = prev.apply(this);
15 if (when === 'after' || when === 'all') {
16 console.log(`${this.name}.${key} has finished.`);
17 }
18 return result;
19 };
20 desc.value = next;
21 };
22}
23
24class User {
25 constructor(name) {
26 this.name = name;
27 }
28 sayHello() {
29 console.log(`Hello, I am ${this.name}.`);
30 }
31}
32__decorate([
33 LogProxy('all')
34], User.prototype, "sayHello", null);
35class JapaneseUser extends User {
36 constructor(name) {
37 super(name);
38 this.name = name;
39 }
40}
41const alice = new User('alice');
42alice.sayHello();
43const arisu = new JapaneseUser('有栖');
44arisu.sayHello();
45//# sourceMappingURL=main.js.map

全てを読まずとも、 __decorate が User.prototype の name に、 decorator 関数を食わせた値を再代入していることが分かります。 下の継承している側の Class では特に defineProperty をしているわけではないので、 Decorator の影響を受け続けています。

そのため、継承した Class でオーバーライドした場合には Decorator の影響は受けません。

src/main.ts
1function LogProxy(when: 'before' | 'after' | 'all') {
2 return function(_target: any, key: string, desc: PropertyDescriptor) {
3 const prev = desc.value;
4 const next = function() {
5 if (when === 'before' || when === 'all') {
6 console.log(`${this.name}.${key} will start.`);
7 }
8 const result = prev.apply(this);
9 if (when === 'after' || when === 'all') {
10 console.log(`${this.name}.${key} has finished.`);
11 }
12 return result;
13 };
14 desc.value = next;
15 };
16}
17
18class User {
19 name: string;
20
21 constructor(name: string) {
22 this.name = name;
23 }
24
25 @LogProxy('all')
26 sayHello() {
27 console.log(`Hello, I am ${this.name}.`);
28 }
29}
30
31class JapaneseUser extends User {
32 name: string;
33
34 constructor(name: string) {
35 super(name);
36 this.name = name;
37 }
38
39 sayHello() {
40 console.log(`こんにちは、私は${this.name}です。`);
41 }
42
43}
44
45const alice = new User('alice');
46alice.sayHello();
47const arisu = new JapaneseUser('有栖');
48arisu.sayHello();
1$ yarn ts-node src/main.ts
2
3alice.sayHello will start.
4Hello, I am alice.
5alice.sayHello has finished.
6こんにちは、私は有栖です。

Property Decorator の挙動を追う

同様に、 Decorator を定義した Class とその子 Class を定義します。 以下で定義する @Effect() は、呼び出し時に呼び出し元とプロパティ名、引数を Container に記録する副作用を持つ Decorator 関数です。

src/main.ts
1let effectContainer = {};
2let effectCounter = 0;
3
4function Effect(str: string) {
5 return function(target: any, key: string) {
6 const className = target.constructor.name;
7 const prev = effectContainer[className];
8 effectContainer[className] = { ...prev, [key]: str };
9 effectCounter++;
10 };
11}
12
13class User {
14 @Effect('decorating User.name property')
15 name: string;
16
17 constructor(name: string) {
18 this.name = name;
19 }
20}
21
22class JapaneseUser extends User {
23 name: string;
24
25 constructor(name: string) {
26 super(name);
27 this.name = name;
28 }
29}
30
31const alice = new User('alice');
32console.log(alice.name)
33const beth = new User('beth');
34console.log(beth.name)
35const arisu = new JapaneseUser('有栖');
36console.log(arisu.name)
37
38console.log(effectContainer);
39console.log(effectCounter);
1$ yarn ts-node src/main.ts
2alice
3beth
4有栖
5{ User: { name: 'decorating User.name property' } }
61

User Class のインスタンスは子 Class 含め複数回生成していますが、 Decorator 関数は 1 度しか呼ばれていません。 コンパイル済みの以下のコードを見ると、 Class 宣言の後に 1 度評価されているのみであることが分かります。

dist/main.js
1var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2 var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3 if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4 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;
5 return c > 3 && r && Object.defineProperty(target, key, r), r;
6};
7let effectContainer = {};
8function Effect(str) {
9 return function (target, key) {
10 const className = target.constructor.name;
11 const prev = effectContainer[className];
12 effectContainer[className] = { ...prev, [key]: str };
13 };
14}
15class User {
16 constructor(name) {
17 this.name = name;
18 }
19}
20__decorate([
21 Effect('decorating name property')
22], User.prototype, "name", void 0);
23class JapaneseUser extends User {
24 constructor(name) {
25 super(name);
26 this.name = name;
27 }
28 sayHello() {
29 console.log(`こんにちは、私は${this.name}です。`);
30 }
31}
32const alice = new User('alice');
33const beth = new User('beth');
34const arisu = new JapaneseUser('有栖');
35console.log(effectContainer);
36//# sourceMappingURL=main.js.map

この例で上げたのが副作用であるのは、 Decorator 関数の返す関数が取れる引数が 2 つのみであり、 PropertyDescripter が存在しないため、呼び出し元の Class に対して何も操作することが現状できないためです。 子 Class に対して定義した場合は、新規の定義として実行されます。

1class JapaneseUser extends User {
2 @Effect("decorating JapaneseUser.name property")
3 name: string;
4
5 constructor(name: string) {
6 super(name);
7 this.name = name;
8 }
9}
1$ yarn ts-node src/main.ts
2alice
3beth
4有栖
5{
6 User: { name: 'decorating User.name property' },
7 JapaneseUser: { name: 'decorating JapaneseUser.name property' }
8}
92

class-validator の Decorator の挙動

class-validator では上記の Property Decorator を使用して定義しますが、その際に Class 名とプロパティ名を Container に記録しています。 内部では instanceof による比較をしているようであるため、 Decorator の定義を継承したような挙動に見えます。

備考: instanceof と子クラスについて

該当する Class のインスタンスであるかの比較に instanceof を使用すると、その子孫クラスと比較した場合も true となります。

1class User {}
2const user = new User();
3user instanceof User; //=> true
4class ExUser extends User {}
5const exUser = new ExUser();
6exUser instanceof User; //=>true

子孫クラスであることを明確に区別したい場合は、 Class 名を取得して比較するのが良いです。

1user.constructor.name === exUser.constructor.name; //=> false

おわりに

この記事では NestJS で多様される Decorator を継承した場合の挙動について説明しました。 Decorator の仕様はまだ安定していないため、今後挙動が変わる可能性がある点はくれぐれもご留意ください。

明日は @potato4d の GitHub Actions を利用した NestJS アプリケーションの Google AppEngine への自動デプロイ です。