사용자 지정 검증기와 비동기 제약 조건
데이터베이스를 대상으로 한 비동기 검사를 포함해 재사용 가능한 @ValidatorConstraint 규칙을 작성합니다
사용자 지정 검증기와 비동기 제약 조건은(는) CoddyKit의 무료 NestJS Enterprise Backend APIs 강의입니다. 이것은 4개 중 2번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 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.
자주 묻는 질문
“사용자 지정 검증기와 비동기 제약 조건” 강의는 무료인가요?
네 — “사용자 지정 검증기와 비동기 제약 조건” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 NestJS Enterprise Backend APIs 강의 전체를 잠금 해제할 수 있습니다. NestJS Enterprise Backend APIs 강의에는 총 4개의 강의가 포함되어 있습니다.
“사용자 지정 검증기와 비동기 제약 조건”에서 뭘 배우나요?
데이터베이스를 대상으로 한 비동기 검사를 포함해 재사용 가능한 @ValidatorConstraint 규칙을 작성합니다 브라우저에서 직접 실행하는 실습 코드로 NestJS Enterprise Backend APIs을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
NestJS Enterprise Backend APIs을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 NestJS Enterprise Backend APIs은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 2번째 강의입니다.
“사용자 지정 검증기와 비동기 제약 조건” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 NestJS Enterprise Backend APIs 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 NestJS Enterprise Backend APIs 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 중첩 객체 및 배열 DTO 검증
- 사용자 지정 검증기와 비동기 제약 조건
- ClassSerializerInterceptor를 활용한 응답 구성
- 조건부 검증과 동적 그룹