Тема 9. Валідація даних та конвеєр обробки запитів у NestJS

Створення кастомних валідаторів

ValidatorConstraintInterface, створення власних правил валідації

Створення кастомних валідаторів

🎯 Мета лекції

  • Зрозуміти необхідність кастомних валідаторів для специфічних бізнес-правил
  • Опанувати інтерфейс ValidatorConstraintInterface для створення власних правил валідації
  • Навчитися створювати багаторазово використовувані декоратори через registerDecorator()
  • Вивчити асинхронні валідатори для перевірки даних у базі даних або зовнішніх API
  • Засвоїти інтеграцію кастомних валідаторів з Dependency Injection для доступу до сервісів
  • Практикувати створення комплексних валідаторів: унікальність, кросс-поля, формати

🔑 Ключові терміни

  • Custom Validator (кастомний валідатор): клас, що реалізує специфічну логіку валідації для доменних правил
  • ValidatorConstraintInterface (інтерфейс обмеження валідатора): контракт для створення валідаторів з методами validate() та defaultMessage()
  • registerDecorator (реєстрація декоратора): утиліта для створення декораторів валідації з власної логіки
  • Async Validator (асинхронний валідатор): валідатор, що виконує асинхронні операції (запити до БД, API)
  • Cross-field Validation (валідація між полями): перевірка залежностей між кількома властивостями DTO
  • ValidationArguments (аргументи валідації): об'єкт з метаданими про поточну валідацію

Коли потрібні кастомні валідатори

Бібліотека class-validator надає понад 100 вбудованих декораторів для типових сценаріїв валідації. Проте в реальних застосунках часто виникають специфічні бізнес-правила, що не покриваються стандартними декораторами:

Сценарії для кастомних валідаторів

  1. Перевірка унікальності: email або username не повинні існувати в базі даних
  2. Складні формати: спеціальні номери телефонів, ідентифікаційні коди, номерні знаки
  3. Доменна логіка: дата завершення має бути після дати початку, вік користувача для певних дій
  4. Кросс-поля валідація: підтвердження пароля має збігатися з оригінальним паролем
  5. Інтеграція з зовнішніми системами: перевірка коду купона через API, валідація адреси через геокодер
  6. Бізнес-обмеження: максимальна кількість активних підписок, ліміти тарифного плану
Створюйте кастомні валідатори лише для правил, що повторюються у кількох місцях або є доменно-специфічними. Для одноразової валідації краще використати логіку безпосередньо в сервісі.

Інтерфейс 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):

ОпціяТипОпис
namestringУнікальна назва валідатора (для ідентифікації)
asyncbooleanЧи є валідатор асинхронним (за замовчуванням 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"] }
Асинхронні валідатори збільшують час обробки запиту через додаткові запити до БД. Для високонавантажених API розгляньте альтернативи: перевірку унікальності в сервісі після валідації або використання унікальних індексів БД з обробкою помилок дублікатів.

Комплексний приклад: валідація діапазону дат

Створимо валідатор для перевірки, що дата завершення події пізніша за дату початку:

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 {}
Валідатори з зовнішніми API-викликами можуть значно сповільнити обробку запитів та створити залежність від доступності зовнішніх сервісів. Розгляньте альтернативи:
  • Перевірка промокоду в сервісі після валідації
  • Кешування результатів валідації
  • 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;
}
Для валідаторів, що залежать від контексту автентифікації (user ID, ролі), передавайте ці дані через DTO або використовуйте 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');
  });
});

Підсумок: патерни кастомних валідаторів

Простий валідатор

Використовує лише вхідне значення, не залежить від зовнішніх сервісів. Приклад: формати, регулярні вирази.

Параметризований

Приймає конфігурацію через constraints. Приклад: MinAge, MaxLength з динамічними обмеженнями.

Кросс-поля

Порівнює кілька властивостей DTO через args.object. Приклад: підтвердження пароля, діапазони дат.

Асинхронний

Виконує запити до БД або API. Приклад: унікальність email, перевірка прав доступу.

З Dependency Injection

Використовує сервіси через конструктор з @Injectable(). Приклад: складна бізнес-логіка з кількома залежностями.

У наступній лекції ми розглянемо створення кастомних Pipes для трансформації даних та специфічної валідації, що не покривається class-validator.

Copyright © 2026