カスタムValidatorと非同期Constraint
データベースへの非同期チェックを含む、再利用可能な@ValidatorConstraintルールを作成します
「カスタムValidatorと非同期Constraint」はCoddyKit上の無料NestJS Enterprise Backend APIsレッスンです。 これはレッスン2/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはNestJS Enterprise Backend APIs学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 NestJS Enterprise Backend APIsコースには全4レッスンが含まれています。
このレッスンの一部はまだ翻訳されておらず、英語で表示されています。
Why Custom Validators?
The class-validator decorators that NestJS ships with (@IsEmail, @Min, @Length) cover generic shapes, but enterprise rules are domain-specific: "this coupon code must exist and not be expired" or "this email must be unique in the users table".
- Custom validators let you encapsulate such rules behind a single reusable decorator.
- They keep DTOs declarative and your business logic out of controllers.
- Two flavors exist: inline (
@Validate/registerDecorator) and constraint classes (@ValidatorConstraint).
In this lesson we focus on the @ValidatorConstraint class approach, including async checks that hit a database.
Anatomy of a ValidatorConstraint
A constraint class implements ValidatorConstraintInterface and is decorated with @ValidatorConstraint. It exposes two methods:
validate(value, args)returnsboolean(orPromise<boolean>for async).defaultMessage(args)returns the error string when validation fails.
The name option is the rule identifier, and async: true tells class-validator to await the result.
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
@ValidatorConstraint({ name: 'isStrongPassword', async: false })
export class IsStrongPasswordConstraint
implements ValidatorConstraintInterface
{
validate(value: string, _args: ValidationArguments): boolean {
if (typeof value !== 'string') return false;
const hasUpper = /[A-Z]/.test(value);
const hasDigit = /[0-9]/.test(value);
return value.length >= 8 && hasUpper && hasDigit;
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} must be 8+ chars with an uppercase letter and a digit`;
}
}Wrapping It in a Decorator
Implementing the constraint is only half the story. To get a clean @IsStrongPassword() decorator you wrap registerDecorator in a factory function.
registerDecoratorbinds your constraint class to a target property.validationOptionslets callers override the message per-field.- The factory returns a
PropertyDecorator, so it reads like a built-in decorator on the DTO.
import { registerDecorator, ValidationOptions } from 'class-validator';
import { IsStrongPasswordConstraint } from './is-strong-password.constraint';
export function IsStrongPassword(options?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [],
validator: IsStrongPasswordConstraint,
});
};
}
// Usage in a DTO:
// class CreateUserDto {
// @IsStrongPassword()
// password: string;
// }The Pure Logic Is Testable
The core of a validator is plain TypeScript with no framework coupling. You can extract and unit-test the predicate in isolation. Below is a standalone program demonstrating the strong-password rule.
function isStrongPassword(value: string): boolean {
if (typeof value !== 'string') return false;
const hasUpper = /[A-Z]/.test(value);
const hasDigit = /[0-9]/.test(value);
return value.length >= 8 && hasUpper && hasDigit;
}
const cases = ['abc', 'alllowercase1', 'Short1', 'GoodPass99'];
for (const c of cases) {
console.log(`${c.padEnd(15)} -> ${isStrongPassword(c)}`);
}Passing Arguments to a Constraint
Validators often need parameters: a minimum age, an allowed currency list, or a sibling field to compare against. You pass them via the constraints array, then read them inside validate through args.constraints.
A classic example is @Match, which checks one property equals another (e.g. passwordConfirm equals password).
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
@ValidatorConstraint({ name: 'match', async: false })
export class MatchConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments): boolean {
const [relatedProperty] = args.constraints as [string];
const related = (args.object as Record<string, unknown>)[relatedProperty];
return value === related;
}
defaultMessage(args: ValidationArguments): string {
const [relatedProperty] = args.constraints as [string];
return `${args.property} must match ${relatedProperty}`;
}
}
export function Match(property: string, options?: ValidationOptions) {
return (object: object, propertyName: string) =>
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [property],
validator: MatchConstraint,
});
}Going Async: Database Constraints
The real enterprise power is async validation: checking a value against the database during request validation. The most common case is uniqueness.
- Set
async: truein@ValidatorConstraint. - Return a
Promise<boolean>fromvalidate. - Inject a repository/service into the constraint class.
For dependency injection to work, the constraint must be injectable and class-validator must use Nest's container (covered in the next scene).
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
import { User } from './user.entity';
@ValidatorConstraint({ name: 'isEmailUnique', async: true })
@Injectable()
export class IsEmailUniqueConstraint
implements ValidatorConstraintInterface
{
constructor(
@InjectRepository(User) private readonly users: Repository<User>,
) {}
async validate(email: string): Promise<boolean> {
const existing = await this.users.findOne({ where: { email } });
return existing === null;
}
defaultMessage(args: ValidationArguments): string {
return `email '${args.value}' is already registered`;
}
}Wiring DI With useContainer
By default class-validator instantiates constraint classes itself, so @Injectable() dependencies are undefined. You must tell class-validator to resolve constraints through Nest's DI container.
- Call
useContainer(app.select(AppModule), { fallbackOnErrors: true })inmain.ts. - Register the constraint as a provider in the module that owns the repository.
fallbackOnErrors: truelets non-injectable built-in validators still work.
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { useContainer } from 'class-validator';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Resolve custom validators via Nest's DI container
useContainer(app.select(AppModule), { fallbackOnErrors: true });
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.listen(3000);
}
bootstrap();Registering the Constraint as a Provider
The injectable constraint only gets its dependencies if Nest knows about it. Add it to the module providers array alongside the feature it validates.
- Import
TypeOrmModule.forFeature([User])so the repository token is available. - List
IsEmailUniqueConstraintinproviders. - If other modules build DTOs using this rule,
exportit.
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UsersService } from './users.service';
import { IsEmailUniqueConstraint } from './is-email-unique.constraint';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService, IsEmailUniqueConstraint],
exports: [IsEmailUniqueConstraint],
})
export class UsersModule {}Using the Async Decorator on a DTO
Once wired, the async rule reads exactly like a sync one. The global ValidationPipe awaits it automatically, so a duplicate email yields a 422/400 response before your controller ever runs.
Compose it with standard decorators — order does not matter, all run and collected errors are merged.
import { IsEmail, IsNotEmpty } from 'class-validator';
import { IsEmailUnique } from './is-email-unique.decorator';
import { IsStrongPassword } from './is-strong-password.decorator';
export class RegisterUserDto {
@IsEmail()
@IsEmailUnique({ message: 'This email is taken' })
email: string;
@IsNotEmpty()
@IsStrongPassword()
password: string;
}Performance and TOCTOU Caveats
Async DB validators are convenient but carry trade-offs you must design around in production:
- Extra query per request — each async rule is a round trip; avoid stacking many on hot endpoints.
- Race condition (TOCTOU) — between the validation read and the actual
INSERT, another request can claim the value. The check is not a substitute for a realUNIQUEconstraint. - Always keep a database-level unique index and catch the duplicate error as the final guard.
Treat async validators as a UX nicety that returns friendly field errors, not as the source of truth for integrity.
Returning Friendly, Targeted Errors
A well-built constraint produces messages tied to the failing field, which the ValidationPipe aggregates into a structured response. You can interpolate the value and property via ValidationArguments.
This standalone snippet simulates how a constraint's validate/defaultMessage pair would behave against a fake user store.
const existingEmails = new Set(['ada@corp.io', 'grace@corp.io']);
function validateUnique(email: string): { ok: boolean; message?: string } {
const ok = !existingEmails.has(email);
return ok ? { ok } : { ok, message: `email '${email}' is already registered` };
}
for (const email of ['ada@corp.io', 'linus@corp.io']) {
const result = validateUnique(email);
console.log(`${email.padEnd(16)} -> ${JSON.stringify(result)}`);
}Quick Check
You added an injectable @ValidatorConstraint({ async: true }) class that injects a TypeORM repository, but at runtime the repository is undefined and the app throws. What is the missing step?
Recap
You can now build reusable, framework-grade validators in NestJS:
- Implement
ValidatorConstraintInterfacewithvalidateanddefaultMessage, decorated by@ValidatorConstraint({ name, async }). - Wrap it in a factory using
registerDecoratorfor a clean@MyRule()decorator, passing parameters via theconstraintsarray. - For async DB checks, make the constraint
@Injectable(), register it as a provider, and calluseContainer(...)inmain.tsso DI works. - Remember the TOCTOU caveat: async uniqueness validators improve UX but must be backed by a database
UNIQUEconstraint for true integrity.
よくある質問
「カスタムValidatorと非同期Constraint」レッスンは無料ですか?
はい。「カスタムValidatorと非同期Constraint」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、NestJS Enterprise Backend APIsコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 NestJS Enterprise Backend APIsコースには全4レッスンが含まれています。
「カスタムValidatorと非同期Constraint」で何を学びますか?
データベースへの非同期チェックを含む、再利用可能な@ValidatorConstraintルールを作成します ブラウザで直接実行するハンズオンコードでNestJS Enterprise Backend APIsを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
NestJS Enterprise Backend APIsを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのNestJS Enterprise Backend APIsは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン2/4です。
「カスタムValidatorと非同期Constraint」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このNestJS Enterprise Backend APIsレッスンでコードを書いて実行できますか?
はい。すべてのNestJS Enterprise Backend APIsレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。
このコースのすべてのレッスン
- ネストしたDTOと配列のValidation
- カスタムValidatorと非同期Constraint
- ClassSerializerInterceptorによるレスポンス整形
- 条件付きバリデーションと動的グループ