DTOのバリデーションを書いていたときはvalidateSync()なんて一度も書いた覚えがないのに、環境変数のバリデーションを書こうとしたら急に出てきて戸惑いました。なぜ片方だけ自分で呼ぶ必要があるのか、調べたことをまとめます。

結論

環境変数はHTTPのパイプラインの外にあるので、自分で書く必要があります。

そもそもデコレータだけでは何も起きない

@IsNotEmpty()のようなデコレータは、あくまでルールを登録しているだけです。値をセットした瞬間にチェックが走るわけではありません。実際にチェックを実行するにはvalidateSync()を呼ぶ必要があります。

これはDTOでも環境変数でも同じで、違うのは「誰がそれを呼ぶか」だけです。

DTOのバリデーション(ValidationPipe)

こちらはHTTPリクエスト専用で、NestJSが自動でやってくれます。

// main.ts — これだけで全コントローラーに適用される
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
// create-sample.dto.ts — ルールを定義するだけ
export class CreateSampleDto {
  @IsNotEmpty()
  @IsString()
  date: string;
}
// sample.controller.ts — validateSyncを書く必要なし
@Post()
async create(@Body() dto: CreateSampleDto) {
  // ここに来た時点でバリデーション済み
  // 不正ならValidationPipeが自動で400を返す
}

@Body() dto: CreateSampleDtoが1行でやっていること

地味にこの1行の情報量が多くて、「取り出し、検証、格納」が全部ここで行われています。

  1. @Body()でリクエストボディを取り出す
  2. CreateSampleDtoという型を見て、ValidationPipeがバリデーションをかける。適用したいルールはこのクラスに書く
  3. バリデーションを通過した値がdtoに入る

useGlobalPipesとは何か

パイプというのは、コントローラーのメソッドが実行される前に引数を加工したり検証したりする処理のことです。useGlobalPipesは「このパイプを全コントローラーの全メソッドにまとめて適用する」という設定になります。

もしuseGlobalPipesを書かないと、こんな感じで全メソッドに個別で指定することになります。

@Post()
async create(@Body(new ValidationPipe()) dto: CreateSampleDto) { ... }

@Put()
async update(@Body(new ValidationPipe()) dto: CreateSampleDto) { ... }

@Patch()
async patch(@Body(new ValidationPipe()) dto: UpdateSampleDto) { ... }

さすがに面倒なので、useGlobalPipesで一括適用しておくのが普通だと思います。

ValidationPipeの内部でやっていること

自分では書いていませんが、裏側ではだいたいこういうことが実行されています。

const instance = plainToClass(CreateSampleDto, requestBody);
const errors = validateSync(instance);
if (errors.length > 0) throw new BadRequestException(errors);

つまりvalidateSync()が消えていたわけではなく、ValidationPipeが代わりに呼んでくれていただけでした。

環境変数のバリデーション(EnvValidator)

一方で環境変数はHTTPリクエストではないので、ValidationPipeの管轄外です。そのためplainToClassからvalidateSyncまでを手で書くことになります。

// env.validation.ts — ルール定義と検証関数を自分で書く
export class EnvValidator {
  @IsNotEmpty()
  @IsString()
  LINE_USER_ID: string;
}

export function validate(config: Record<string, unknown>) {
  const instance = plainToClass(EnvValidator, config); // 1. インスタンス化
  const errors = validateSync(instance); // 2. チェック実行
  if (errors.length > 0) throw new Error(errors.toString());
  return instance;
}

あとはこのvalidateをConfigModuleに渡せば、アプリ起動時にチェックが走ります。

// app.module.ts
ConfigModule.forRoot({ validate });

比較 (うーん、)

DTO (ValidationPipe)EnvValidator
データの出所HTTPリクエストのbody.envファイル
plainToClassValidationPipeが自動自分で呼ぶ
validateSyncValidationPipeが自動自分で呼ぶ
実行タイミングリクエストごとアプリ起動時に1回
失敗時の挙動400 Bad Request起動失敗(throw)

まとめ

どちらも内部ではplainToClassからvalidateSyncという2ステップを踏んでいて、DTOの場合はそれをNestJSのValidationPipeが自動でやってくれています。環境変数はHTTPのパイプラインの外にあるので、そのぶんを自分で書く必要があるというだけの話でした。

仕組みが分かってしまえば当たり前なのですが、「なぜかこっちだけ書き方が違う」と思っていた間はずっとモヤモヤしていたので、同じところで止まる人がいれば参考になればと思います。