Створення кастомних валідаторів
Створення кастомних валідаторів
🎯 Мета лекції
- Зрозуміти необхідність кастомних валідаторів для специфічних бізнес-правил
- Опанувати інтерфейс
ValidatorConstraintInterfaceдля створення власних правил валідації - Навчитися створювати багаторазово використовувані декоратори через
registerDecorator() - Вивчити асинхронні валідатори для перевірки даних у базі даних або зовнішніх API
- Засвоїти інтеграцію кастомних валідаторів з Dependency Injection для доступу до сервісів
- Практикувати створення комплексних валідаторів: унікальність, кросс-поля, формати
🔑 Ключові терміни
- Custom Validator (кастомний валідатор): клас, що реалізує специфічну логіку валідації для доменних правил
- ValidatorConstraintInterface (інтерфейс обмеження валідатора): контракт для створення валідаторів з методами
validate()таdefaultMessage() - registerDecorator (реєстрація декоратора): утиліта для створення декораторів валідації з власної логіки
- Async Validator (асинхронний валідатор): валідатор, що виконує асинхронні операції (запити до БД, API)
- Cross-field Validation (валідація між полями): перевірка залежностей між кількома властивостями DTO
- ValidationArguments (аргументи валідації): об'єкт з метаданими про поточну валідацію
Коли потрібні кастомні валідатори
Бібліотека class-validator надає понад 100 вбудованих декораторів для типових сценаріїв валідації. Проте в реальних застосунках часто виникають специфічні бізнес-правила, що не покриваються стандартними декораторами:
Сценарії для кастомних валідаторів
- Перевірка унікальності: email або username не повинні існувати в базі даних
- Складні формати: спеціальні номери телефонів, ідентифікаційні коди, номерні знаки
- Доменна логіка: дата завершення має бути після дати початку, вік користувача для певних дій
- Кросс-поля валідація: підтвердження пароля має збігатися з оригінальним паролем
- Інтеграція з зовнішніми системами: перевірка коду купона через API, валідація адреси через геокодер
- Бізнес-обмеження: максимальна кількість активних підписок, ліміти тарифного плану
Інтерфейс ValidatorConstraintInterface
Всі кастомні валідатори реалізують інтерфейс ValidatorConstraintInterface, що визначає два методи:
interface ValidatorConstraintInterface {
// Логіка валідації: повертає true якщо валідно, false якщо ні
validate(value: any, validationArguments?: ValidationArguments): boolean | Promise<boolean>;
// Повідомлення про помилку за замовчуванням
defaultMessage?(validationArguments?: ValidationArguments): string;
}
Структура ValidationArguments
Об'єкт ValidationArguments надає контекст про поточну валідацію:
interface ValidationArguments {
value: any; // Значення властивості, що валідується
constraints: any[]; // Масив аргументів, переданих у декоратор
targetName: string; // Назва класу DTO
object: object; // Весь об'єкт DTO (для кросс-поля валідації)
property: string; // Назва властивості, що валідується
}
Створення простого кастомного валідатора
Розглянемо створення валідатора для перевірки українського номера телефону формату +380XXXXXXXXX:
Крок 1: Клас валідатора
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
@ValidatorConstraint({ name: 'isUkrainianPhone', async: false })
export class IsUkrainianPhoneConstraint implements ValidatorConstraintInterface {
validate(phoneNumber: string, args: ValidationArguments): boolean {
if (!phoneNumber || typeof phoneNumber !== 'string') {
return false;
}
// Regex для українських номерів: +380XXXXXXXXX (12 цифр загалом)
const ukrainianPhoneRegex = /^\+380\d{9}$/;
return ukrainianPhoneRegex.test(phoneNumber);
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} must be a valid Ukrainian phone number (+380XXXXXXXXX)`;
}
}
Декоратор @ValidatorConstraint(options):
| Опція | Тип | Опис |
|---|---|---|
name | string | Унікальна назва валідатора (для ідентифікації) |
async | boolean | Чи є валідатор асинхронним (за замовчуванням false) |
Крок 2: Створення декоратора
Тепер створимо декоратор @IsUkrainianPhone() для зручного використання:
import { registerDecorator, ValidationOptions } from 'class-validator';
export function IsUkrainianPhone(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsUkrainianPhoneConstraint,
});
};
}
Параметри registerDecorator():
| Параметр | Опис |
|---|---|
target | Клас, до якого застосовується декоратор |
propertyName | Назва властивості |
options | Опції валідації (кастомне повідомлення, групи) |
constraints | Масив додаткових аргументів для валідатора |
validator | Клас валідатора або посилання на нього |
Крок 3: Використання в DTO
import { IsString, IsEmail } from 'class-validator';
import { IsUkrainianPhone } from './validators/is-ukrainian-phone.validator';
export class CreateContactDto {
@IsString()
name: string;
@IsEmail()
email: string;
@IsUkrainianPhone({
message: 'Номер телефону має бути у форматі +380XXXXXXXXX',
})
phone: string;
}
Приклади запитів:
# ✅ Валідний
POST /contacts
{
"name": "Іван Петренко",
"email": "ivan@example.com",
"phone": "+380671234567"
}
# ❌ Невалідний формат
POST /contacts
{
"name": "Іван Петренко",
"email": "ivan@example.com",
"phone": "0671234567"
}
# Помилка: "Номер телефону має бути у форматі +380XXXXXXXXX"
src/common/validators/ для повторного використання в різних модулях застосунку.Валідатор з параметрами: MinAge
Створимо валідатор, що приймає конфігурацію (мінімальний вік):
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
registerDecorator,
ValidationOptions,
} from 'class-validator';
@ValidatorConstraint({ name: 'minAge', async: false })
export class MinAgeConstraint implements ValidatorConstraintInterface {
validate(birthDate: Date, args: ValidationArguments): boolean {
if (!(birthDate instanceof Date) || isNaN(birthDate.getTime())) {
return false;
}
const [minAge] = args.constraints; // Отримуємо параметр з декоратора
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
// Перевірка, чи відбувся день народження цього року
const hasHadBirthdayThisYear =
monthDiff > 0 || (monthDiff === 0 && today.getDate() >= birthDate.getDate());
const actualAge = hasHadBirthdayThisYear ? age : age - 1;
return actualAge >= minAge;
}
defaultMessage(args: ValidationArguments): string {
const [minAge] = args.constraints;
return `User must be at least ${minAge} years old`;
}
}
// Декоратор з параметром
export function MinAge(minAge: number, validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [minAge], // Передаємо параметр у валідатор
validator: MinAgeConstraint,
});
};
}
Використання:
import { Type } from 'class-transformer';
import { IsDate } from 'class-validator';
import { MinAge } from './validators/min-age.validator';
export class RegisterDto {
@IsString()
name: string;
@Type(() => Date)
@IsDate()
@MinAge(18, {
message: 'You must be at least 18 years old to register',
})
birthDate: Date;
}
constraints у ValidationArguments. Ви можете передавати скільки завгодно параметрів: constraints: [minAge, maxAge, unit].Кросс-поля валідація: підтвердження пароля
Валідатор, що порівнює дві властивості об'єкта:
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
registerDecorator,
ValidationOptions,
} from 'class-validator';
@ValidatorConstraint({ name: 'isFieldsMatch', async: false })
export class IsFieldsMatchConstraint implements ValidatorConstraintInterface {
validate(value: any, args: ValidationArguments): boolean {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return value === relatedValue;
}
defaultMessage(args: ValidationArguments): string {
const [relatedPropertyName] = args.constraints;
return `${args.property} must match ${relatedPropertyName}`;
}
}
// Декоратор для перевірки збігу полів
export function IsFieldsMatch(
property: string,
validationOptions?: ValidationOptions,
) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [property],
validator: IsFieldsMatchConstraint,
});
};
}
Використання:
export class ChangePasswordDto {
@IsString()
@MinLength(8)
newPassword: string;
@IsString()
@IsFieldsMatch('newPassword', {
message: 'Password confirmation does not match password',
})
confirmPassword: string;
}
Запит:
{
"newPassword": "SecurePass123!",
"confirmPassword": "DifferentPass"
}
// Помилка: "Password confirmation does not match password"
newPassword відсутній, порівняння з undefined поверне false. Для опційних полів додайте перевірку @IsOptional() на обидва поля.Асинхронні валідатори: перевірка унікальності
Найпотужніша можливість кастомних валідаторів — це асинхронні операції, такі як запити до бази даних:
import { Injectable } from '@nestjs/common';
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
import { UsersService } from '../users/users.service';
@ValidatorConstraint({ name: 'isEmailUnique', async: true })
@Injectable() // Дозволяє DI
export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
constructor(private readonly usersService: UsersService) {}
async validate(email: string, args: ValidationArguments): Promise<boolean> {
const user = await this.usersService.findByEmail(email);
// Повертаємо true якщо користувач НЕ знайдений (email унікальний)
return !user;
}
defaultMessage(args: ValidationArguments): string {
return `Email "${args.value}" is already taken`;
}
}
// Декоратор
export function IsEmailUnique(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: IsEmailUniqueConstraint,
});
};
}
Реєстрація асинхронного валідатора в модулі
Асинхронні валідатори з @Injectable() мають бути зареєстровані як провайдери:
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { IsEmailUniqueConstraint } from './validators/is-email-unique.validator';
@Module({
controllers: [UsersController],
providers: [
UsersService,
IsEmailUniqueConstraint, // Реєструємо валідатор
],
})
export class UsersModule {}
Використання:
export class RegisterDto {
@IsEmail()
@IsEmailUnique({
message: 'This email is already registered',
})
email: string;
@IsString()
@MinLength(8)
password: string;
}
Запит:
POST /auth/register
{
"email": "existing@example.com",
"password": "SecurePass123"
}
# Якщо email вже існує:
# 400 Bad Request
# { "message": ["This email is already registered"] }
Комплексний приклад: валідація діапазону дат
Створимо валідатор для перевірки, що дата завершення події пізніша за дату початку:
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
registerDecorator,
ValidationOptions,
} from 'class-validator';
@ValidatorConstraint({ name: 'isDateAfter', async: false })
export class IsDateAfterConstraint implements ValidatorConstraintInterface {
validate(endDate: Date, args: ValidationArguments): boolean {
const [startDateProperty] = args.constraints;
const startDate = (args.object as any)[startDateProperty];
if (!(endDate instanceof Date) || !(startDate instanceof Date)) {
return false;
}
// Перевірка, що endDate пізніше за startDate
return endDate.getTime() > startDate.getTime();
}
defaultMessage(args: ValidationArguments): string {
const [startDateProperty] = args.constraints;
return `${args.property} must be after ${startDateProperty}`;
}
}
export function IsDateAfter(
property: string,
validationOptions?: ValidationOptions,
) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [property],
validator: IsDateAfterConstraint,
});
};
}
Використання:
import { Type } from 'class-transformer';
import { IsDate, IsString } from 'class-validator';
import { IsDateAfter } from './validators/is-date-after.validator';
export class CreateEventDto {
@IsString()
title: string;
@Type(() => Date)
@IsDate()
startDate: Date;
@Type(() => Date)
@IsDate()
@IsDateAfter('startDate', {
message: 'Event end date must be after start date',
})
endDate: Date;
}
Запит:
{
"title": "Conference 2026",
"startDate": "2026-09-10T09:00:00Z",
"endDate": "2026-09-09T18:00:00Z"
}
// Помилка: "Event end date must be after start date"
Валідатор з перевіркою зовнішнього API
Створимо валідатор, що перевіряє промокод через зовнішній API:
import { Injectable, HttpService } from '@nestjs/common';
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
import { lastValueFrom } from 'rxjs';
@ValidatorConstraint({ name: 'isValidPromoCode', async: true })
@Injectable()
export class IsValidPromoCodeConstraint implements ValidatorConstraintInterface {
constructor(private readonly httpService: HttpService) {}
async validate(promoCode: string, args: ValidationArguments): Promise<boolean> {
if (!promoCode || typeof promoCode !== 'string') {
return false;
}
try {
// Запит до зовнішнього API для перевірки промокоду
const response = await lastValueFrom(
this.httpService.get(`https://promo-api.example.com/validate/${promoCode}`)
);
return response.data.isValid === true;
} catch (error) {
// Якщо API недоступний, вважаємо промокод невалідним
return false;
}
}
defaultMessage(args: ValidationArguments): string {
return `Promo code "${args.value}" is invalid or expired`;
}
}
export function IsValidPromoCode(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: IsValidPromoCodeConstraint,
});
};
}
Реєстрація з HttpModule:
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { IsValidPromoCodeConstraint } from './validators/is-valid-promo-code.validator';
@Module({
imports: [HttpModule], // Для HttpService
controllers: [OrdersController],
providers: [
OrdersService,
IsValidPromoCodeConstraint,
],
})
export class OrdersModule {}
- Перевірка промокоду в сервісі після валідації
- Кешування результатів валідації
- Timeout для API-запитів
- Fallback-логіка при недоступності API
Валідація з Dependency Injection: складна логіка
Створимо валідатор, що перевіряє, чи може користувач створити новий проєкт на основі його тарифного плану:
import { Injectable } from '@nestjs/common';
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
import { UsersService } from '../users/users.service';
@ValidatorConstraint({ name: 'canCreateProject', async: true })
@Injectable()
export class CanCreateProjectConstraint implements ValidatorConstraintInterface {
constructor(private readonly usersService: UsersService) {}
async validate(value: any, args: ValidationArguments): Promise<boolean> {
// Отримуємо userId з об'єкта DTO
const dto = args.object as any;
const userId = dto.userId;
if (!userId) {
return false;
}
const user = await this.usersService.findById(userId);
if (!user) {
return false;
}
const projectCount = await this.usersService.getProjectCount(userId);
const limit = user.subscription.projectLimit;
// Перевірка ліміту на основі тарифного плану
return projectCount < limit;
}
defaultMessage(args: ValidationArguments): string {
return 'You have reached your project limit. Please upgrade your subscription';
}
}
export function CanCreateProject(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: CanCreateProjectConstraint,
});
};
}
Використання:
export class CreateProjectDto {
@IsString()
userId: string; // Передається з JWT-токена або контексту
@IsString()
@CanCreateProject({
message: 'Project limit exceeded for your current subscription',
})
name: string; // Валідація застосована до name, але перевіряє userId
@IsString()
@IsOptional()
description?: string;
}
ExecutionContext у Guards для перевірки до валідації.Групування валідаторів: організація коду
Для великих застосунків створіть централізовану структуру валідаторів:
src/
├── common/
│ └── validators/
│ ├── index.ts # Експорт всіх валідаторів
│ ├── decorators/
│ │ ├── is-ukrainian-phone.decorator.ts
│ │ ├── is-email-unique.decorator.ts
│ │ ├── min-age.decorator.ts
│ │ └── is-fields-match.decorator.ts
│ └── constraints/
│ ├── is-ukrainian-phone.constraint.ts
│ ├── is-email-unique.constraint.ts
│ ├── min-age.constraint.ts
│ └── is-fields-match.constraint.ts
└── users/
└── dto/
└── register.dto.ts
index.ts (barrel export):
export * from './decorators/is-ukrainian-phone.decorator';
export * from './decorators/is-email-unique.decorator';
export * from './decorators/min-age.decorator';
export * from './decorators/is-fields-match.decorator';
export * from './constraints/is-ukrainian-phone.constraint';
export * from './constraints/is-email-unique.constraint';
export * from './constraints/min-age.constraint';
export * from './constraints/is-fields-match.constraint';
Використання:
import { IsUkrainianPhone, IsEmailUnique, MinAge } from '@/common/validators';
export class RegisterDto {
@IsEmailUnique()
email: string;
@IsUkrainianPhone()
phone: string;
@MinAge(18)
birthDate: Date;
}
Тестування кастомних валідаторів
Валідатори мають бути покриті unit-тестами для гарантування коректності:
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { IsUkrainianPhoneConstraint } from './is-ukrainian-phone.constraint';
describe('IsUkrainianPhoneConstraint', () => {
let constraint: IsUkrainianPhoneConstraint;
beforeEach(() => {
constraint = new IsUkrainianPhoneConstraint();
});
it('should validate correct Ukrainian phone number', () => {
const result = constraint.validate('+380671234567', {} as any);
expect(result).toBe(true);
});
it('should reject phone without country code', () => {
const result = constraint.validate('0671234567', {} as any);
expect(result).toBe(false);
});
it('should reject phone with wrong country code', () => {
const result = constraint.validate('+48671234567', {} as any);
expect(result).toBe(false);
});
it('should reject phone with incorrect length', () => {
const result = constraint.validate('+38067123', {} as any);
expect(result).toBe(false);
});
it('should reject non-string values', () => {
const result = constraint.validate(123456789, {} as any);
expect(result).toBe(false);
});
});
Тестування з DTO:
class TestDto {
@IsUkrainianPhone()
phone: string;
}
describe('IsUkrainianPhone decorator', () => {
it('should pass validation for valid phone', async () => {
const dto = plainToInstance(TestDto, { phone: '+380671234567' });
const errors = await validate(dto);
expect(errors.length).toBe(0);
});
it('should fail validation for invalid phone', async () => {
const dto = plainToInstance(TestDto, { phone: 'invalid' });
const errors = await validate(dto);
expect(errors.length).toBe(1);
expect(errors[0].constraints).toHaveProperty('isUkrainianPhone');
});
});
Переконайтеся, що:
- У
@ValidatorConstraint()встановленоasync: true - Метод
validate()повертаєPromise<boolean> - Валідатор зареєстрований як провайдер у модулі (якщо використовує DI)
- ValidationPipe налаштований коректно (за замовчуванням підтримує async)
Якщо валідатор все одно не виконується, перевірте, чи не перехоплюють виконання інші pipes або guards.
ValidationArguments не має доступу до HTTP-контексту. Для передачі контекстних даних:
Варіант 1: Додайте дані в DTO (наприклад, через middleware або guards)
Варіант 2: Використовуйте Guards для валідації на основі контексту замість pipes
Варіант 3: Впровадьте REQUEST-scoped провайдер у валідатор для доступу до поточного запиту
@ValidatorConstraint({ async: true })
@Injectable({ scope: Scope.REQUEST })
export class CustomConstraint {
constructor(@Inject(REQUEST) private request: Request) {}
async validate(value: any) {
const userId = this.request['user']?.id;
// Використовуємо userId у валідації
}
}
Якщо в асинхронному валідаторі виникне виключення (наприклад, помилка БД), ValidationPipe поверне 500 Internal Server Error. Для коректної обробки:
async validate(value: any, args: ValidationArguments): Promise<boolean> {
try {
const result = await this.someService.check(value);
return result;
} catch (error) {
console.error('Validation error:', error);
// Повертаємо false замість throw
return false;
}
}
Альтернативно, логуйте помилки в систему моніторингу та повертайте false для відхилення невалідних даних.
Підсумок: патерни кастомних валідаторів
Простий валідатор
Параметризований
constraints. Приклад: MinAge, MaxLength з динамічними обмеженнями.Кросс-поля
args.object. Приклад: підтвердження пароля, діапазони дат.Асинхронний
З Dependency Injection
@Injectable(). Приклад: складна бізнес-логіка з кількома залежностями.У наступній лекції ми розглянемо створення кастомних Pipes для трансформації даних та специфічної валідації, що не покривається class-validator.