blog.euxn.me

TypeScript の Decorator Hell を解消する

2019-12-18 Wed.

これを解決します。

src/models/user.ts
1import { IsNotEmpty, MaxLength } from 'class-validator';
2import { Column, PrimaryGeneratedColumn } from 'typeorm';
3import { ApiProperty } from '@nestjs/swagger';
4
5export class User {
6 @PrimaryGeneratedColumn()
7 @ApiProperty({ example: 1 })
8 id!: number;
9
10 @IsNotEmpty()
11 @MaxLength(16)
12 @Column()
13 @ApiProperty({ example: 'alice07' })
14 displayId!: string;
15
16 @IsNotEmpty()
17 @MaxLength(16)
18 @Column()
19 @ApiProperty({ example: 'alice' })
20 name!: string;
21
22 @MaxLength(140)
23 @Column('text')
24 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })
25 profileText?: string;
26
27 @Column()
28 createdAt!: number;
29
30 @Column()
31 updatedAt!: number;
32}

この記事は NestJS アドベントカレンダー 2019 18 日目の記事です。

はじめに

NestJS + ClassValidator + TypeORM 、という構成などのときに、上記のような Decorator Hell を想像してしまうことはあると思います。 動くものとしては十分ですが、メンテナンス性を高めるために、 Abstract Class と Interface を活用して分離し、依存関係を整理する一例を紹介します。

https://github.com/nestjs-jp/advent-calendar-2019/tree/master/day18-avoid-decorator-hell

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

現実装の Decorator の挙動については Decorator と継承 にも書いていますので併せてお読み下さい。

Validator を分離する

1export class ValidatableUser {
2 id!: number;
3
4 @IsNotEmpty()
5 @MaxLength(16)
6 displayId!: string;
7
8 @IsNotEmpty()
9 @MaxLength(16)
10 name!: string;
11
12 @MaxLength(140)
13 profileText?: string;
14
15 createdAt!: number;
16 updatedAt!: number;
17}
18
19export class User extends ValidatableUser {
20 @PrimaryGeneratedColumn()
21 @ApiProperty({ example: 1 })
22 id!: number;
23
24 @Column()
25 @ApiProperty({ example: "alice07" })
26 displayId!: string;
27
28 @Column()
29 @ApiProperty({ example: "alice" })
30 name!: string;
31
32 @Column("text")
33 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })
34 profileText?: string;
35
36 @Column()
37 createdAt!: number;
38
39 @Column()
40 updatedAt!: number;
41}

class-validator が継承した Class でも validation ができることを利用し、 validation の定義を親クラスに移譲します。 以下のコードを実行すると、バリデーションエラーが発生します。

1import { User } from "./src/models/user";
2import { validate } from "class-validator";
3
4async function main() {
5 const user = new User();
6 user.id = 1;
7 user.displayId = "alice1234567890123456";
8 user.name = "alice";
9
10 const err = await validate(user, { skipMissingProperties: true });
11 console.log(err);
12}
13
14main().catch(console.error);

API 層を分離する

API レスポンスとして使用される / Swagger のドキュメント生成に使用される Class を別に定義します。

1import { IsNotEmpty, MaxLength } from "class-validator";
2import { Column, PrimaryGeneratedColumn } from "typeorm";
3import { ApiProperty } from "@nestjs/swagger";
4
5export class ValidatableUser {
6 id!: number;
7
8 @IsNotEmpty()
9 @MaxLength(16)
10 displayId!: string;
11
12 @IsNotEmpty()
13 @MaxLength(16)
14 name!: string;
15
16 @MaxLength(140)
17 profileText?: string;
18
19 createdAt!: number;
20 updatedAt!: number;
21}
22
23export class User extends ValidatableUser {
24 @PrimaryGeneratedColumn()
25 id!: number;
26
27 @Column()
28 displayId!: string;
29
30 @Column()
31 name!: string;
32
33 @Column("text")
34 profileText?: string;
35
36 @Column()
37 createdAt!: number;
38
39 @Column()
40 updatedAt!: number;
41}
42
43type TransferUserType = Omit<User, "createdAt" | "updatedAt">;
44
45export class TransferUser extends User implements TransferUserType {
46 @ApiProperty({ example: 1 })
47 id!: number;
48
49 @ApiProperty({ example: "alice07" })
50 displayId!: string;
51
52 @ApiProperty({ example: "alice" })
53 name!: string;
54
55 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })
56 profileText?: string;
57}
src/app.controller.ts
1import { Controller, Get, HttpException, Query } from '@nestjs/common';
2import { TransferUser } from './models/user';
3import { ApiResponse } from '@nestjs/swagger';
4import { validate } from 'class-validator';
5
6@Controller()
7export class AppController {
8 @Get()
9 @ApiResponse({ status: 200, type: TransferUser })
10 @ApiResponse({ status: 400 })
11 async getUser(
12 @Query() { displayId, name }: { displayId: string; name: string },
13 ): Promise<TransferUser> {
14 if (!displayId || !name) {
15 throw new HttpException('displayId and name are required', 400);
16 }
17
18 const user = new TransferUser();
19 user.id = 123;
20 user.displayId = displayId;
21 user.name = name;
22
23 const errs = await validate(user, { skipMissingProperties: true });
24
25 if (errs.length) {
26 console.error(errs);
27 throw new HttpException(errs, 400);
28 }
29
30 console.log(user);
31
32 return user;
33 }
34}
1$ curl localhost:3000\?displayId=alice07\&name=alice
2{"id":123,"displayId":"alice07","name":"alice"}
3
4$ curl localhost:3000\?displayId=alice1234567890123456\&name=alice
5[{"target":{"id":123,"displayId":"alice1234567890123456","name":"alice"},"value":"alice1234567890123456","property":"displayId","children":[],"constraints":{"maxLength":"displayId must be shorter than or equal to 16 characters"}}]

TypeORM 層を分離する

次に、 User Class から TypeORM の Decorator を分離します。

1import { IsNotEmpty, MaxLength } from "class-validator";
2import { Column, PrimaryGeneratedColumn } from "typeorm";
3import { ApiProperty } from "@nestjs/swagger";
4
5export class ValidatableUser {
6 id!: number;
7
8 @IsNotEmpty()
9 @MaxLength(16)
10 displayId!: string;
11
12 @IsNotEmpty()
13 @MaxLength(16)
14 name!: string;
15
16 @MaxLength(140)
17 profileText?: string;
18
19 createdAt!: number;
20 updatedAt!: number;
21}
22
23export class User extends ValidatableUser {
24 id!: number;
25 displayId!: string;
26 name!: string;
27 profileText?: string;
28 createdAt!: number;
29 updatedAt!: number;
30}
31
32type SerializableUserType = Omit<User, "createdAt" | "updatedAt">;
33
34export class SerializableUser extends User implements SerializableUserType {
35 @ApiProperty({ example: 1 })
36 id!: number;
37
38 @ApiProperty({ example: "alice07" })
39 displayId!: string;
40
41 @ApiProperty({ example: "alice" })
42 name!: string;
43
44 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })
45 profileText?: string;
46}
47
48export class UserEntity extends User {
49 @PrimaryGeneratedColumn()
50 id!: number;
51
52 @Column()
53 displayId!: string;
54
55 @Column()
56 name!: string;
57
58 @Column("text")
59 profileText?: string;
60
61 @Column()
62 createdAt!: number;
63
64 @Column()
65 updatedAt!: number;
66}

ロジックを持ち基底となる Pure な User を用意し、整理する

上記の手順で User Class は class-validator を継承しているため、基底とは言えません。 なので、基底となる、 Decorator のない Pure TypeScript な User Class として定義するよう、継承関係を整理します。 また、ここで実装される toObject メソッドは User を継承した全ての Class で使用できるメソッドになります。

1export class User {
2 id: number;
3 displayId: string;
4 name: string;
5 profileText?: string;
6 createdAt?: number;
7 updatedAt?: number;
8
9 constructor({
10 id,
11 displayId,
12 name,
13 profileText,
14 createdAt,
15 updatedAt,
16 }: User) {
17 this.id = id;
18 this.displayId = displayId;
19 this.name = name;
20 this.profileText = profileText;
21 this.createdAt = createdAt;
22 this.updatedAt = updatedAt;
23 }
24
25 toObject() {
26 return {
27 id: this.id,
28 displayId: this.displayId,
29 name: this.name,
30 profileText: this.profileText,
31 createdAt: this.createdAt,
32 updatedAt: this.updatedAt,
33 };
34 }
35}
36
37export class ValidatableUser extends User {
38 id!: number;
39
40 @IsNotEmpty()
41 @MaxLength(16)
42 displayId!: string;
43
44 @IsNotEmpty()
45 @MaxLength(16)
46 name!: string;
47
48 @MaxLength(140)
49 profileText?: string;
50
51 createdAt!: number;
52 updatedAt!: number;
53}
54
55type TransferUserType = Omit<User, "createdAt" | "updatedAt">;
56
57export class TransferUser extends ValidatableUser implements TransferUserType {
58 @ApiProperty({ example: 1 })
59 id!: number;
60
61 @ApiProperty({ example: "alice07" })
62 displayId!: string;
63
64 @ApiProperty({ example: "alice" })
65 name!: string;
66
67 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })
68 profileText?: string;
69
70 toObject() {
71 return {
72 id: this.id,
73 displayId: this.displayId,
74 name: this.name,
75 profileText: this.profileText,
76 };
77 }
78}
79
80export class UserEntity extends ValidatableUser {
81 @PrimaryGeneratedColumn()
82 id!: number;
83
84 @Column()
85 displayId!: string;
86
87 @Column()
88 name!: string;
89
90 @Column("text")
91 profileText?: string;
92
93 @Column()
94 createdAt!: number;
95
96 @Column()
97 updatedAt!: number;
98}

Abstract Class 、 Interface を活用し整理する

最後に、 インスタンス化しないものを Abstract Class 化します。 この Abstract Class も、 toObject された値も、ともに満たす Interface を定義し実装します。

1export interface UserInterface {
2 id: number;
3 displayId: string;
4 name: string;
5 profileText?: string;
6 createdAt?: number;
7 updatedAt?: number;
8}
9
10export abstract class AbstractUser implements UserInterface {
11 id: number;
12 displayId: string;
13 name: string;
14 profileText?: string;
15 createdAt?: number;
16 updatedAt?: number;
17
18 constructor({
19 id,
20 displayId,
21 name,
22 profileText,
23 createdAt,
24 updatedAt,
25 }: UserInterface) {
26 this.id = id;
27 this.displayId = displayId;
28 this.name = name;
29 this.profileText = profileText;
30 this.createdAt = createdAt;
31 this.updatedAt = updatedAt;
32 }
33
34 toObject(): UserInterface {
35 return {
36 id: this.id,
37 displayId: this.displayId,
38 name: this.name,
39 profileText: this.profileText,
40 createdAt: this.createdAt,
41 updatedAt: this.updatedAt,
42 };
43 }
44}
45
46export abstract class ValidatableUser extends AbstractUser {
47 id!: number;
48
49 @IsNotEmpty()
50 @MaxLength(16)
51 displayId!: string;
52
53 @IsNotEmpty()
54 @MaxLength(16)
55 name!: string;
56
57 @MaxLength(140)
58 profileText?: string;
59
60 createdAt?: number;
61 updatedAt?: number;
62}
63
64export type TransferUserType = Omit<UserInterface, "createdAt" | "updatedAt">;
65
66export class User extends ValidatableUser {
67 @ApiProperty({ example: 1 })
68 id!: number;
69
70 @ApiProperty({ example: "alice07" })
71 displayId!: string;
72
73 @ApiProperty({ example: "alice" })
74 name!: string;
75
76 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })
77 profileText?: string;
78
79 toObject() {
80 return {
81 id: this.id,
82 displayId: this.displayId,
83 name: this.name,
84 profileText: this.profileText,
85 };
86 }
87}
88
89export class UserEntity extends ValidatableUser {
90 @PrimaryGeneratedColumn()
91 id!: number;
92
93 @Column()
94 displayId!: string;
95
96 @Column()
97 name!: string;
98
99 @Column("text")
100 profileText?: string;
101
102 @Column()
103 createdAt?: number;
104
105 @Column()
106 updatedAt?: number;
107}

この状態でも、ロジック(Controller にロジックを書くべきではないとは思いますが例なので)側からは自然に見えるように思います。

src/app.controller.ts
1@Controller()
2export class AppController {
3 @Get()
4 @ApiResponse({ status: 200, type: User })
5 @ApiResponse({ status: 400 })
6 async getUser(
7 @Query() { displayId, name }: { displayId: string; name: string },
8 ): Promise<UserInterface> {
9 if (!displayId || !name) {
10 throw new HttpException('displayId and name are required', 400);
11 }
12
13 const user = new User({ id: 123, displayId, name });
14
15 const errs = await validate(user, { skipMissingProperties: true });
16
17 if (errs.length) {
18 console.error(errs);
19 throw new HttpException(errs, 400);
20 }
21
22 console.log(user);
23
24 return user.toObject();
25 }
26}

ここまで分離する必要があるかどうかはケースバイケースかと思いますが、 Decorator を提供する複数のライブラリに同時に依存してしまうリスクをある程度排除し、同時にメンテナンス性もある程度担保できるかと思います。

おわりに

NestJS + ClassValidator + TypeORM 、という構成などのときに、 Abstract Class と Interface を活用して Decorator Hell を解消する方法の一例を紹介しました。 この方法が全てのプロジェクトに当てはまるわけではありませんが、参考にしていただければ幸いです。