IsDecimalはなぜ数値を検証できないのか?
NestJSでの class-validator は、データのバリデーションを非常に簡単にしてくれる強力なツールです。DTO(Data Transfer Object)にいくつかのデコレーターを追加するだけで、受信リクエストの形式を簡単に制御できます。しかし、時には予想と異なる動作をするデコレーターに悩まされることもあります。その一つが @IsDecimal です。
明らかに小数点付きの数値を送ったのに「有効な10進数ではありません」というエラーが出た経験はありませんか?この記事では、なぜ @IsDecimal がこのような問題を引き起こすのか、そしてNestJSで小数点のある数値を正しく検証する方法を明確に説明します。
@IsDecimal の誤解と真実
多くの開発者は @IsDecimal を数値型の小数を検証するために使用しようとします。しかし、Stack Overflow でも議論されているように、@IsDecimal は 文字列(string)型の値が10進数形式に合っているかどうか を確認するデコレーターです。
実際、class-validator の GitHub イシュー (#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-validator は price フィールドが数値型であるため、@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.ts に ConfigModule の設定を追加するのが一般的です。
// 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のイシューやコミュニティで質問し、しっかり理解して進めるのが良いでしょう!