0Pricing
NestJS Enterprise Backend APIs · レッスン

カスタム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) returns boolean (or Promise<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.

  • registerDecorator binds your constraint class to a target property.
  • validationOptions lets 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: true in @ValidatorConstraint.
  • Return a Promise<boolean> from validate.
  • 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 }) in main.ts.
  • Register the constraint as a provider in the module that owns the repository.
  • fallbackOnErrors: true lets 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 IsEmailUniqueConstraint in providers.
  • If other modules build DTOs using this rule, export it.
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 real UNIQUE constraint.
  • 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 ValidatorConstraintInterface with validate and defaultMessage, decorated by @ValidatorConstraint({ name, async }).
  • Wrap it in a factory using registerDecorator for a clean @MyRule() decorator, passing parameters via the constraints array.
  • For async DB checks, make the constraint @Injectable(), register it as a provider, and call useContainer(...) in main.ts so DI works.
  • Remember the TOCTOU caveat: async uniqueness validators improve UX but must be backed by a database UNIQUE constraint 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フィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. ネストしたDTOと配列のValidation
  2. カスタムValidatorと非同期Constraint
  3. ClassSerializerInterceptorによるレスポンス整形
  4. 条件付きバリデーションと動的グループ
← NestJS Enterprise Backend APIsに戻る