IsDecimalはなぜ数値を検証できないのか?

IsDecimalはなぜ数値を検証できないのか?

D
dongAuthor
2 min read

NestJSでの class-validator は、データのバリデーションを非常に簡単にしてくれる強力なツールです。DTO(Data Transfer Object)にいくつかのデコレーターを追加するだけで、受信リクエストの形式を簡単に制御できます。しかし、時には予想と異なる動作をするデコレーターに悩まされることもあります。その一つが @IsDecimal です。

明らかに小数点付きの数値を送ったのに「有効な10進数ではありません」というエラーが出た経験はありませんか?この記事では、なぜ @IsDecimal がこのような問題を引き起こすのか、そしてNestJSで小数点のある数値を正しく検証する方法を明確に説明します。

@IsDecimal の誤解と真実

多くの開発者は @IsDecimal を数値型の小数を検証するために使用しようとします。しかし、Stack Overflow でも議論されているように、@IsDecimal文字列(string)型の値が10進数形式に合っているかどうか を確認するデコレーターです。

実際、class-validatorGitHub イシュー (#1423) にも、@IsDecimal の説明コメントが誤っていて混乱を招いた過去の記録があります。現在は修正されていますが、それでも多くの開発者がこの点で混乱しています。

次のようなコードをご覧ください。

// create-product.dto.ts

import { IsDecimal, IsNotEmpty, Min } from 'class-validator';

export class CreateProductDto {
  @IsDecimal({ decimal_digits: '2' }) // 🚨 ここで問題が発生します!
  @IsNotEmpty()
  @Min(0)
  price: number; // 型は 'number' です。
}

このDTOにJSON形式で { "price": 1574.23 } のようなリクエストを送ると、class-validatorprice フィールドが数値型であるため、@IsDecimal の検証に通らずエラーを返します。

では、数値型の小数はどうやって検証すれば良いのでしょうか?2つの解決策があります。

解決策1: @IsNumber を使用する

最もシンプルで直感的な解決策は、@IsNumber デコレーターを使用することです。@IsNumber は数値型の値を検証し、maxDecimalPlaces オプションを使って許容される小数点以下の桁数を指定できます。

// create-product.dto.ts(修正後)

import { IsNumber, IsNotEmpty, Min } from 'class-validator';

export class CreateProductDto {
  @IsNumber({ maxDecimalPlaces: 2 }) // ✅ このように修正してください!
  @IsNotEmpty()
  @Min(0)
  price: number;
}

このように修正すれば、price フィールドが数値であり、小数点以下2桁までを許可するように正確に検証できます。

ヒント:データベースの精度を保証するために、TypeORMなどのORMを使っている場合は、エンティティ(Entity)ファイルで @Column('decimal') デコレーターも一緒に定義しておくと良いでしょう。

解決策2: カスタムデコレーターを作成する

より複雑なバリデーションルールが必要な場合は、カスタムデコレーターを自作する方法もあります。exonerate のようなライブラリを使えば、複数のルールを組み合わせて強力なカスタムデコレーターを簡単に作成できます。

まずライブラリをインストールしてください。

npm install exonerate

次に、DTOで @Exonerate デコレーターを使用して、以下のように様々なルールを適用できます。

// create-user.dto.ts(exonerate使用例)

import { Exonerate } from 'exonerate';
import { User } from './user.entity'; // Userエンティティが存在すると仮定
import { AddressDto } from './address.dto'; // Address DTOが存在すると仮定

enum ROLE {
  ADMIN = 'ADMIN',
  USER = 'USER',
}

export class CreateUserDto {
    @Exonerate({ rules: 'required|string|max:20|min:4|exist:name', entity: User })
    name: string;

    @Exonerate({ rules: 'required|email|unique:email', entity: User })
    email: string;

    @Exonerate({
        rules: 'required|max:20|min:8|pattern',
        regexPattern: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/
    })
    password: string;

    @Exonerate({ rules: 'required|enum', enumType: ROLE })
    role: string;
}

exonerate は小数点の検証だけでなく、メールアドレスの重複確認(unique:email)、特定パターンの検証(pattern)など、複合的なルールを簡潔に表現できるようにしてくれます。プロジェクト全体で一貫性のある再利用可能なバリデーションロジックが必要な場合に非常に便利です。

グローバルパイプの設定

class-validator をアプリケーション全体で動作させるには、main.ts ファイルに ValidationPipe を追加する必要があります。この設定を忘れないようにしましょう!

// main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // ✅ グローバルなバリデーションパイプを適用します。
  app.useGlobalPipes(new ValidationPipe());
  
  await app.listen(3000);
}
bootstrap();

また、環境変数などを使う場合は app.module.tsConfigModule の設定を追加するのが一般的です。

// app.module.ts

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
    // ... 他のモジュール
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

正しいツールを正しい場所で

今回の内容から、重要な教訓を得ました。@IsDecimal は文字列用のツールであり、数値型の小数点を検証するには @IsNumber を使うべきだということです。

コードを書く際には、デコレーターや関数の説明を丁寧に確認する習慣を持つことで、予期せぬバグを防ぐ強力な防御手段になります。もし公式ドキュメントの説明が不十分だったり、混乱を招くようであれば、遠慮せずにGitHubのイシューやコミュニティで質問し、しっかり理解して進めるのが良いでしょう!

参考リンク