TypeScript の Decorator Hell を解消する
これを解決します。
src/models/user.ts
1import { IsNotEmpty, MaxLength } from 'class-validator';2import { Column, PrimaryGeneratedColumn } from 'typeorm';3import { ApiProperty } from '@nestjs/swagger';45export class User {6 @PrimaryGeneratedColumn()7 @ApiProperty({ example: 1 })8 id!: number;910 @IsNotEmpty()11 @MaxLength(16)12 @Column()13 @ApiProperty({ example: 'alice07' })14 displayId!: string;1516 @IsNotEmpty()17 @MaxLength(16)18 @Column()19 @ApiProperty({ example: 'alice' })20 name!: string;2122 @MaxLength(140)23 @Column('text')24 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })25 profileText?: string;2627 @Column()28 createdAt!: number;2930 @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;34 @IsNotEmpty()5 @MaxLength(16)6 displayId!: string;78 @IsNotEmpty()9 @MaxLength(16)10 name!: string;1112 @MaxLength(140)13 profileText?: string;1415 createdAt!: number;16 updatedAt!: number;17}1819export class User extends ValidatableUser {20 @PrimaryGeneratedColumn()21 @ApiProperty({ example: 1 })22 id!: number;2324 @Column()25 @ApiProperty({ example: "alice07" })26 displayId!: string;2728 @Column()29 @ApiProperty({ example: "alice" })30 name!: string;3132 @Column("text")33 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })34 profileText?: string;3536 @Column()37 createdAt!: number;3839 @Column()40 updatedAt!: number;41}
class-validator が継承した Class でも validation ができることを利用し、 validation の定義を親クラスに移譲します。 以下のコードを実行すると、バリデーションエラーが発生します。
1import { User } from "./src/models/user";2import { validate } from "class-validator";34async function main() {5 const user = new User();6 user.id = 1;7 user.displayId = "alice1234567890123456";8 user.name = "alice";910 const err = await validate(user, { skipMissingProperties: true });11 console.log(err);12}1314main().catch(console.error);
API 層を分離する
API レスポンスとして使用される / Swagger のドキュメント生成に使用される Class を別に定義します。
1import { IsNotEmpty, MaxLength } from "class-validator";2import { Column, PrimaryGeneratedColumn } from "typeorm";3import { ApiProperty } from "@nestjs/swagger";45export class ValidatableUser {6 id!: number;78 @IsNotEmpty()9 @MaxLength(16)10 displayId!: string;1112 @IsNotEmpty()13 @MaxLength(16)14 name!: string;1516 @MaxLength(140)17 profileText?: string;1819 createdAt!: number;20 updatedAt!: number;21}2223export class User extends ValidatableUser {24 @PrimaryGeneratedColumn()25 id!: number;2627 @Column()28 displayId!: string;2930 @Column()31 name!: string;3233 @Column("text")34 profileText?: string;3536 @Column()37 createdAt!: number;3839 @Column()40 updatedAt!: number;41}4243type TransferUserType = Omit<User, "createdAt" | "updatedAt">;4445export class TransferUser extends User implements TransferUserType {46 @ApiProperty({ example: 1 })47 id!: number;4849 @ApiProperty({ example: "alice07" })50 displayId!: string;5152 @ApiProperty({ example: "alice" })53 name!: string;5455 @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';56@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 }1718 const user = new TransferUser();19 user.id = 123;20 user.displayId = displayId;21 user.name = name;2223 const errs = await validate(user, { skipMissingProperties: true });2425 if (errs.length) {26 console.error(errs);27 throw new HttpException(errs, 400);28 }2930 console.log(user);3132 return user;33 }34}
1$ curl localhost:3000\?displayId=alice07\&name=alice2{"id":123,"displayId":"alice07","name":"alice"}34$ curl localhost:3000\?displayId=alice1234567890123456\&name=alice5[{"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";45export class ValidatableUser {6 id!: number;78 @IsNotEmpty()9 @MaxLength(16)10 displayId!: string;1112 @IsNotEmpty()13 @MaxLength(16)14 name!: string;1516 @MaxLength(140)17 profileText?: string;1819 createdAt!: number;20 updatedAt!: number;21}2223export class User extends ValidatableUser {24 id!: number;25 displayId!: string;26 name!: string;27 profileText?: string;28 createdAt!: number;29 updatedAt!: number;30}3132type SerializableUserType = Omit<User, "createdAt" | "updatedAt">;3334export class SerializableUser extends User implements SerializableUserType {35 @ApiProperty({ example: 1 })36 id!: number;3738 @ApiProperty({ example: "alice07" })39 displayId!: string;4041 @ApiProperty({ example: "alice" })42 name!: string;4344 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })45 profileText?: string;46}4748export class UserEntity extends User {49 @PrimaryGeneratedColumn()50 id!: number;5152 @Column()53 displayId!: string;5455 @Column()56 name!: string;5758 @Column("text")59 profileText?: string;6061 @Column()62 createdAt!: number;6364 @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;89 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 }2425 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}3637export class ValidatableUser extends User {38 id!: number;3940 @IsNotEmpty()41 @MaxLength(16)42 displayId!: string;4344 @IsNotEmpty()45 @MaxLength(16)46 name!: string;4748 @MaxLength(140)49 profileText?: string;5051 createdAt!: number;52 updatedAt!: number;53}5455type TransferUserType = Omit<User, "createdAt" | "updatedAt">;5657export class TransferUser extends ValidatableUser implements TransferUserType {58 @ApiProperty({ example: 1 })59 id!: number;6061 @ApiProperty({ example: "alice07" })62 displayId!: string;6364 @ApiProperty({ example: "alice" })65 name!: string;6667 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })68 profileText?: string;6970 toObject() {71 return {72 id: this.id,73 displayId: this.displayId,74 name: this.name,75 profileText: this.profileText,76 };77 }78}7980export class UserEntity extends ValidatableUser {81 @PrimaryGeneratedColumn()82 id!: number;8384 @Column()85 displayId!: string;8687 @Column()88 name!: string;8990 @Column("text")91 profileText?: string;9293 @Column()94 createdAt!: number;9596 @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}910export abstract class AbstractUser implements UserInterface {11 id: number;12 displayId: string;13 name: string;14 profileText?: string;15 createdAt?: number;16 updatedAt?: number;1718 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 }3334 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}4546export abstract class ValidatableUser extends AbstractUser {47 id!: number;4849 @IsNotEmpty()50 @MaxLength(16)51 displayId!: string;5253 @IsNotEmpty()54 @MaxLength(16)55 name!: string;5657 @MaxLength(140)58 profileText?: string;5960 createdAt?: number;61 updatedAt?: number;62}6364export type TransferUserType = Omit<UserInterface, "createdAt" | "updatedAt">;6566export class User extends ValidatableUser {67 @ApiProperty({ example: 1 })68 id!: number;6970 @ApiProperty({ example: "alice07" })71 displayId!: string;7273 @ApiProperty({ example: "alice" })74 name!: string;7576 @ApiProperty({ example: `Hello, I'm NestJS Programmer!` })77 profileText?: string;7879 toObject() {80 return {81 id: this.id,82 displayId: this.displayId,83 name: this.name,84 profileText: this.profileText,85 };86 }87}8889export class UserEntity extends ValidatableUser {90 @PrimaryGeneratedColumn()91 id!: number;9293 @Column()94 displayId!: string;9596 @Column()97 name!: string;9899 @Column("text")100 profileText?: string;101102 @Column()103 createdAt?: number;104105 @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 }1213 const user = new User({ id: 123, displayId, name });1415 const errs = await validate(user, { skipMissingProperties: true });1617 if (errs.length) {18 console.error(errs);19 throw new HttpException(errs, 400);20 }2122 console.log(user);2324 return user.toObject();25 }26}
ここまで分離する必要があるかどうかはケースバイケースかと思いますが、 Decorator を提供する複数のライブラリに同時に依存してしまうリスクをある程度排除し、同時にメンテナンス性もある程度担保できるかと思います。
おわりに
NestJS + ClassValidator + TypeORM 、という構成などのときに、 Abstract Class と Interface を活用して Decorator Hell を解消する方法の一例を紹介しました。 この方法が全てのプロジェクトに当てはまるわけではありませんが、参考にしていただければ幸いです。