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

Декоратори class-validator для DTO

@IsString, @IsInt, @IsEmail, @Min, @Max, @Length, @IsOptional

Декоратори class-validator для DTO

🎯 Мета лекції

  • Опанувати повний арсенал декораторів class-validator для різних типів даних
  • Навчитися комбінувати декоратори для створення складних правил валідації
  • Вивчити валідацію вкладених об'єктів та масивів через @ValidateNested() та @Type()
  • Засвоїти умовну валідацію через @ValidateIf() та @IsOptional()
  • Зрозуміти кастомізацію повідомлень помилок для зручності клієнтів API
  • Практикувати створення типобезпечних DTO-класів для різних доменних сутностей

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

  • Decorator (декоратор): функція TypeScript, що прикріплює метадані до класу, властивості або методу
  • Validation Constraint (обмеження валідації): правило, що визначає допустимі значення для властивості
  • Nested Validation (вкладена валідація): валідація об'єктів усередині об'єктів (рекурсивна перевірка)
  • Conditional Validation (умовна валідація): валідація, що застосовується лише за певних умов
  • Custom Message (кастомне повідомлення): персоналізоване повідомлення про помилку валідації
  • Type Coercion (приведення типу): автоматичне перетворення значення з одного типу в інший

Базові декоратори типів

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

Рядки: @IsString()

Перевіряє, що значення є рядком (typeof value === 'string'):

import { IsString, MinLength, MaxLength } from 'class-validator';

export class CreateArticleDto {
  @IsString()
  @MinLength(5, {
    message: 'Title is too short. Minimum length is $constraint1 characters',
  })
  @MaxLength(200, {
    message: 'Title is too long. Maximum length is $constraint1 characters',
  })
  title: string;

  @IsString()
  @MinLength(50)
  @MaxLength(10000)
  content: string;
}

Супутні декоратори для рядків:

ДекораторОписПриклад
@Length(min, max)Довжина у діапазоні@Length(3, 20)
@MinLength(min)Мінімальна довжина@MinLength(8)
@MaxLength(max)Максимальна довжина@MaxLength(255)
@Matches(pattern)Відповідність регулярному виразу@Matches(/^[a-zA-Z0-9]+$/)
@IsNotEmpty()Не порожній рядок@IsNotEmpty()
Декоратори застосовуються зверху вниз. Рекомендується спочатку перевіряти тип (@IsString()), потім структуру та обмеження (@MinLength(), @Matches()).

Числа: @IsNumber() та @IsInt()

import { IsInt, IsNumber, Min, Max, IsPositive } from 'class-validator';

export class CreateProductDto {
  @IsString()
  name: string;

  @IsNumber({ maxDecimalPlaces: 2 }, {
    message: 'Price must be a number with up to 2 decimal places',
  })
  @IsPositive()
  @Max(1000000)
  price: number; // Дозволяє десяткові дроби: 19.99

  @IsInt()
  @Min(0)
  @Max(10000)
  stockQuantity: number; // Лише цілі числа: 42
}

Супутні декоратори для чисел:

ДекораторОписПриклад
@IsInt()Ціле число@IsInt()
@IsNumber(options)Число (з опціями для десяткових знаків)@IsNumber({ maxDecimalPlaces: 2 })
@Min(value)Мінімальне значення@Min(0)
@Max(value)Максимальне значення@Max(100)
@IsPositive()Додатне число (> 0)@IsPositive()
@IsNegative()Від'ємне число (< 0)@IsNegative()
@IsDivisibleBy(num)Кратне числу@IsDivisibleBy(5)

Булеві значення: @IsBoolean()

import { IsBoolean } from 'class-validator';

export class UpdateSettingsDto {
  @IsBoolean()
  emailNotifications: boolean;

  @IsBoolean()
  darkMode: boolean;
}
@IsBoolean() приймає лише true або false. Рядки "true" та "false"не проходять валідацію без опції transform: true у ValidationPipe. Для query string параметрів використовуйте ParseBoolPipe або увімкніть трансформацію.

Спеціалізовані декоратори форматів

Email: @IsEmail()

Валідує електронну адресу згідно зі стандартами RFC:

import { IsEmail } from 'class-validator';

export class RegisterDto {
  @IsEmail({}, {
    message: 'Please provide a valid email address',
  })
  email: string;
}

Опції @IsEmail():

@IsEmail({
  allow_display_name: false,    // Дозволити "Name <email@example.com>"
  require_display_name: false,  // Вимагати формат з ім'ям
  allow_utf8_local_part: true,  // Дозволити Unicode символи
  require_tld: true,            // Вимагати домен верхнього рівня (.com, .org)
})
email: string;

URL: @IsUrl()

Валідує формат URL:

import { IsUrl } from 'class-validator';

export class CreateWebsiteDto {
  @IsUrl({
    protocols: ['https'], // Дозволити лише HTTPS
    require_protocol: true,
    require_valid_protocol: true,
  }, {
    message: 'Website must be a valid HTTPS URL',
  })
  websiteUrl: string;
}

UUID: @IsUUID()

Валідує формат UUID (альтернатива ParseUUIDPipe):

import { IsUUID } from 'class-validator';

export class GetUserDto {
  @IsUUID('4', {
    message: 'User ID must be a valid UUIDv4',
  })
  userId: string;
}

Підтримувані версії: '3', '4', '5', 'all'.

Дата та час: @IsDate()

Валідує, що значення є об'єктом Date:

import { IsDate, MinDate, MaxDate } from 'class-validator';
import { Type } from 'class-transformer';

export class CreateEventDto {
  @IsString()
  title: string;

  @Type(() => Date) // Перетворити рядок ISO 8601 → Date
  @IsDate()
  @MinDate(new Date())
  startDate: Date; // Дата не може бути в минулому

  @Type(() => Date)
  @IsDate()
  endDate: Date;
}
@IsDate() перевіряє instanceof Date, тому JSON-рядки (наприклад, "2026-09-04") не пройдуть валідацію без трансформації. Завжди використовуйте@Type(() => Date) з class-transformer для автоматичного перетворення рядків у об'єкти Date.

Enum-валідація: @IsEnum()

Перевіряє, чи належить значення до визначеного TypeScript enum:

import { IsEnum } from 'class-validator';

export enum UserRole {
  Admin = 'admin',
  User = 'user',
  Moderator = 'moderator',
}

export enum Priority {
  Low = 1,
  Medium = 2,
  High = 3,
}

export class CreateTaskDto {
  @IsString()
  title: string;

  @IsEnum(Priority, {
    message: 'Priority must be 1 (Low), 2 (Medium), or 3 (High)',
  })
  priority: Priority;

  @IsEnum(UserRole)
  assignedRole: UserRole;
}

Запит:

{
  "title": "Fix bug",
  "priority": 2,
  "assignedRole": "admin"
}

Якщо клієнт надішле "priority": 5 або "assignedRole": "superuser", ValidationPipe викине BadRequestException.

Для enum з числовими значеннями class-validator автоматично перетворює рядки у числа при увімкненій опції transform: true.

Опціональні поля: @IsOptional()

За замовчуванням всі властивості DTO є обов'язковими. Декоратор @IsOptional() дозволяє пропустити валідацію, якщо значення є null або undefined:

import { IsOptional, IsString, IsInt, Min } from 'class-validator';

export class UpdateUserDto {
  @IsOptional()
  @IsString()
  @MinLength(2)
  name?: string; // Може бути відсутнім

  @IsOptional()
  @IsInt()
  @Min(18)
  age?: number; // Може бути відсутнім

  @IsOptional()
  @IsEmail()
  email?: string; // Може бути відсутнім
}

Поведінка:

// ✅ Валідний запит: оновлюємо лише ім'я
{
  "name": "John Doe"
}

// ✅ Валідний запит: оновлюємо всі поля
{
  "name": "John Doe",
  "age": 30,
  "email": "john@example.com"
}

// ❌ Невалідний запит: name занадто короткий
{
  "name": "J"
}
// Помилка: "name must be longer than or equal to 2 characters"
@IsOptional() має бути першим декоратором у ланцюгу. Якщо його розмістити після інших декораторів, валідація може працювати некоректно.

Валідація масивів: @IsArray() та пов'язані декоратори

Масиви потребують спеціальної валідації як для самої структури (чи є значення масивом), так і для елементів усередині масиву.

Базова валідація масиву

import { IsArray, ArrayMinSize, ArrayMaxSize, IsString } from 'class-validator';

export class CreatePostDto {
  @IsString()
  title: string;

  @IsArray()
  @ArrayMinSize(1, {
    message: 'Post must have at least one tag',
  })
  @ArrayMaxSize(10, {
    message: 'Post cannot have more than 10 tags',
  })
  @IsString({ each: true }) // Валідувати кожен елемент масиву
  tags: string[];
}

Запит:

{
  "title": "Introduction to NestJS",
  "tags": ["typescript", "backend", "nodejs"]
}

Ключова опція each: true:

Без each: true декоратор @IsString() валідував би сам масив, а не його елементи. З each: true валідація застосовується до кожного елемента масиву окремо.

@IsArray()
@IsString() // Перевіряє typeof tags === 'string' (завжди false)
tags: string[];

// Результат: помилка "tags must be a string"

Масиви об'єктів: @ValidateNested()

Для валідації масивів складних об'єктів використовується комбінація @ValidateNested() та @Type():

import { IsArray, ValidateNested, IsString, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';

// DTO для елемента масиву
export class OrderItemDto {
  @IsString()
  productId: string;

  @IsInt()
  @Min(1)
  quantity: number;

  @IsNumber({ maxDecimalPlaces: 2 })
  @IsPositive()
  price: number;
}

// Основний DTO
export class CreateOrderDto {
  @IsString()
  customerId: string;

  @IsArray()
  @ValidateNested({ each: true }) // Валідувати кожен об'єкт
  @Type(() => OrderItemDto)       // Перетворити plain objects → OrderItemDto
  items: OrderItemDto[];
}

Запит:

{
  "customerId": "user-123",
  "items": [
    {
      "productId": "prod-1",
      "quantity": 2,
      "price": 19.99
    },
    {
      "productId": "prod-2",
      "quantity": 1,
      "price": 49.50
    }
  ]
}

Якщо будь-який елемент масиву items не відповідає OrderItemDto, ValidationPipe поверне детальні помилки:

{
  "statusCode": 400,
  "message": [
    "items.0.quantity must be a positive number",
    "items.1.price must be a number"
  ],
  "error": "Bad Request"
}
@Type(() => OrderItemDto) є обов'язковим для вкладених об'єктів. Без нього class-transformer не зможе перетворити plain objects у екземпляри класів, і валідація декораторів OrderItemDto не спрацює.

Додаткові декоратори для масивів

ДекораторОписПриклад
@ArrayNotEmpty()Масив не порожній@ArrayNotEmpty()
@ArrayMinSize(num)Мінімальна довжина@ArrayMinSize(1)
@ArrayMaxSize(num)Максимальна довжина@ArrayMaxSize(100)
@ArrayUnique()Всі елементи унікальні@ArrayUnique()
@ArrayContains(values)Містить певні значення@ArrayContains(['required'])
@ArrayNotContains(values)Не містить певні значення@ArrayNotContains(['forbidden'])

Вкладені об'єкти: @ValidateNested()

Для валідації об'єктів усередині об'єктів використовується @ValidateNested():

import { ValidateNested, IsString, IsInt, IsEmail } from 'class-validator';
import { Type } from 'class-transformer';

// Вкладений об'єкт адреси
export class AddressDto {
  @IsString()
  street: string;

  @IsString()
  city: string;

  @IsString()
  @Matches(/^\d{5}(-\d{4})?$/, {
    message: 'Postal code must be in format 12345 or 12345-6789',
  })
  postalCode: string;

  @IsString()
  country: string;
}

// Основний DTO користувача
export class CreateUserDto {
  @IsString()
  name: string;

  @IsEmail()
  email: string;

  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto; // Вкладений об'єкт
}

Запит:

{
  "name": "John Doe",
  "email": "john@example.com",
  "address": {
    "street": "123 Main St",
    "city": "New York",
    "postalCode": "10001",
    "country": "USA"
  }
}

Валідація рекурсивно перевірить всі правила AddressDto. Повідомлення про помилки будуть вказувати вкладену структуру:

{
  "statusCode": 400,
  "message": [
    "address.postalCode must match /^\\d{5}(-\\d{4})?$/ regular expression",
    "address.country must be a string"
  ],
  "error": "Bad Request"
}
Вкладені об'єкти можна використовувати для створення композитних DTO, що представляють складні доменні сутності. Наприклад, CreateOrderDto може містити CustomerDto, AddressDto, масив OrderItemDto[] тощо.

Умовна валідація: @ValidateIf()

Декоратор @ValidateIf() дозволяє застосовувати валідацію лише за певних умов:

import { IsString, IsEmail, IsEnum, ValidateIf } from 'class-validator';

export enum ContactMethod {
  Email = 'email',
  Phone = 'phone',
}

export class CreateContactDto {
  @IsString()
  name: string;

  @IsEnum(ContactMethod)
  preferredContactMethod: ContactMethod;

  // Email обов'язковий лише якщо preferredContactMethod === 'email'
  @ValidateIf(o => o.preferredContactMethod === ContactMethod.Email)
  @IsEmail()
  email?: string;

  // Phone обов'язковий лише якщо preferredContactMethod === 'phone'
  @ValidateIf(o => o.preferredContactMethod === ContactMethod.Phone)
  @Matches(/^\+?[\d\s-]{10,}$/)
  phone?: string;
}

Поведінка:

// ✅ Валідний: вибрано email, phone відсутній
{
  "name": "John Doe",
  "preferredContactMethod": "email",
  "email": "john@example.com"
}

// ✅ Валідний: вибрано phone, email відсутній
{
  "name": "Jane Smith",
  "preferredContactMethod": "phone",
  "phone": "+1-555-0100"
}

// ❌ Невалідний: вибрано email, але email відсутній
{
  "name": "Alice",
  "preferredContactMethod": "email"
}
// Помилка: "email must be an email"

Складні умови

@ValidateIf() приймає функцію (object, value) => boolean, що дозволяє створювати складну логіку:

export class UpdateProfileDto {
  @IsOptional()
  @IsString()
  username?: string;

  // Новий пароль обов'язковий лише якщо надано currentPassword
  @ValidateIf((o, value) => o.currentPassword !== undefined || value !== undefined)
  @IsString()
  @MinLength(8)
  newPassword?: string;

  @ValidateIf(o => o.newPassword !== undefined)
  @IsString()
  currentPassword?: string;
}
@ValidateIf()пропускає всі наступні декоратори, якщо умова повертає false. Це відрізняється від @IsOptional(), який пропускає валідацію лише для undefined/null.

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

Всі декоратори class-validator приймають другий аргумент для налаштування повідомлень про помилки:

import { IsString, MinLength, MaxLength, IsEmail, Matches } from 'class-validator';

export class RegisterDto {
  @IsString({
    message: 'Username must be a text value',
  })
  @MinLength(3, {
    message: 'Username is too short. It must be at least $constraint1 characters long',
  })
  @MaxLength(20, {
    message: 'Username is too long. Maximum length is $constraint1 characters',
  })
  @Matches(/^[a-zA-Z0-9_]+$/, {
    message: 'Username can only contain letters, numbers, and underscores',
  })
  username: string;

  @IsEmail({}, {
    message: 'Please provide a valid email address',
  })
  email: string;

  @IsString()
  @MinLength(8, {
    message: 'Password must be at least $constraint1 characters long',
  })
  @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
    message: 'Password must contain at least one uppercase letter, one lowercase letter, and one number',
  })
  password: string;
}

Шаблонні змінні в повідомленнях

class-validator підтримує кілька шаблонних змінних для динамічних повідомлень:

ЗміннаОписПриклад
$propertyНазва властивостіusername
$valueПоточне значення"ab"
$constraint1Перший параметр декоратораУ @MinLength(8) → 8
$constraint2Другий параметр декоратораУ @Length(3, 20) → 3 та 20
$targetНазва класу DTORegisterDto

Приклад використання:

@MinLength(8, {
  message: 'Field "$property" with value "$value" is too short. Minimum is $constraint1 characters',
})
password: string;

// Запит: { "password": "123" }
// Помилка: 'Field "password" with value "123" is too short. Minimum is 8 characters'

Функціональні повідомлення

Для складної логіки повідомлень використовуйте функцію:

@MinLength(8, {
  message: (args) => {
    if (args.value === undefined) {
      return `${args.property} is required`;
    }
    return `${args.property} must be at least ${args.constraints[0]} characters long, but you provided only ${args.value.length}`;
  },
})
password: string;
Кастомні повідомлення покращують user experience API, особливо для публічних API. Замість загальних повідомлень "minLength" клієнти отримують чіткі інструкції, що саме потрібно виправити.

Практичний приклад: повний DTO для реєстрації

Об'єднаємо всі вивчені декоратори в реалістичний DTO:

import {
  IsString,
  IsEmail,
  IsEnum,
  IsOptional,
  IsDate,
  ValidateNested,
  MinLength,
  MaxLength,
  Matches,
  Min,
  Max,
} from 'class-validator';
import { Type } from 'class-transformer';

export enum Gender {
  Male = 'male',
  Female = 'female',
  Other = 'other',
}

export class AddressDto {
  @IsString()
  @MinLength(5)
  street: string;

  @IsString()
  @MinLength(2)
  city: string;

  @IsString()
  @Matches(/^\d{5}$/, { message: 'Postal code must be 5 digits' })
  postalCode: string;

  @IsString()
  country: string;
}

export class RegisterUserDto {
  @IsString({ message: 'Username must be a string' })
  @MinLength(3, { message: 'Username must be at least 3 characters' })
  @MaxLength(20, { message: 'Username must not exceed 20 characters' })
  @Matches(/^[a-zA-Z0-9_]+$/, {
    message: 'Username can only contain letters, numbers, and underscores',
  })
  username: string;

  @IsEmail({}, { message: 'Please provide a valid email address' })
  email: string;

  @IsString()
  @MinLength(8, { message: 'Password must be at least 8 characters' })
  @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/, {
    message: 'Password must contain uppercase, lowercase, number, and special character',
  })
  password: string;

  @IsString()
  @MinLength(2)
  @MaxLength(50)
  firstName: string;

  @IsString()
  @MinLength(2)
  @MaxLength(50)
  lastName: string;

  @Type(() => Date)
  @IsDate()
  @Max(new Date().getTime(), {
    message: 'Birth date cannot be in the future',
  })
  birthDate: Date;

  @IsEnum(Gender, {
    message: 'Gender must be one of: male, female, other',
  })
  gender: Gender;

  @IsOptional()
  @ValidateNested()
  @Type(() => AddressDto)
  address?: AddressDto;
}

Цей DTO забезпечує комплексну валідацію з детальними повідомленнями про помилки для зручності клієнтів API.

Підсумок: категорії декораторів class-validator

Базові типи

@IsString(), @IsNumber(), @IsInt(), @IsBoolean(), @IsDate()

Формати

@IsEmail(), @IsUrl(), @IsUUID(), @IsPhoneNumber(), @IsIP()

Діапазони

@Min(), @Max(), @Length(), @MinLength(), @MaxLength(), @MinDate(), @MaxDate()

Масиви

@IsArray(), @ArrayMinSize(), @ArrayMaxSize(), @ArrayUnique(), each: true

Об'єкти

@ValidateNested(), @Type(), @IsDefined(), @IsObject()

Умови

@IsOptional(), @ValidateIf(), @ValidatePromise()

У наступній лекції ми розглянемо створення власних валідаторів через клас ValidatorConstraint для складних бізнес-правил, що не покриваються стандартними декораторами.

Copyright © 2026