Валидация DTO

В прикладных API слой передачи данных (DTO — Data Transfer Object) используется как формальный контракт между клиентом и сервером. DTO фиксирует структуру входных и выходных данных, позволяя отделить доменную модель от внешнего интерфейса.

В JavaScript/TypeScript-проектах DTO особенно часто применяются в связке с фреймворками наподобие NestJS, где входящие HTTP-запросы преобразуются в экземпляры классов. Это открывает возможность применять декларативную валидацию через декораторы, не смешивая бизнес-логику и проверку данных.

Декларативная валидация через class-validator

Библиотека Class-validator предоставляет механизм валидации объектов на основе декораторов классов. Основная идея заключается в том, что правила описываются прямо в DTO-классе, а проверка выполняется отдельно.

Ключевые особенности подхода:

  • валидация описывается декларативно;
  • правила привязываются к полям класса;
  • поддерживается вложенная проверка объектов;
  • возможна кастомизация через собственные валидаторы.

Базовое описание DTO с правилами

DTO представляет собой обычный класс, где каждое поле снабжается набором декораторов:

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

export class CreateUserDto {
  @IsString()
  @MinLength(3)
  @MaxLength(20)
  username: string;

  @IsInt()
  age: number;
}

Каждый декоратор добавляет метаданные, которые затем используются валидатором для проверки объекта.

Основные типы проверок:

  • проверка типа (@IsString, @IsNumber, @IsBoolean);
  • проверка длины (@MinLength, @MaxLength);
  • проверка диапазонов (@Min, @Max);
  • проверка формата (@IsEmail, @IsUUID, @IsDate);
  • логические ограничения (@IsOptional, @IsNotEmpty).

Процесс валидации DTO

Валидация выполняется через функцию validate или validateOrReject:

import { validate } from 'class-validator';

const dto = new CreateUserDto();
dto.username = 'ab';
dto.age = 25;

validate(dto).then(errors => {
  console.log(errors);
});

Если объект не соответствует правилам, возвращается массив ошибок, каждая из которых содержит:

  • имя свойства;
  • нарушенное правило;
  • сообщение;
  • значение, вызвавшее ошибку.

При использовании validateOrReject вместо массива ошибок выбрасывается исключение, что удобно для серверной обработки.

DTO в контексте HTTP-запросов

В реальных приложениях DTO формируется из тела запроса. Важно, что входные данные приходят в виде plain object, поэтому перед валидацией часто требуется преобразование в экземпляр класса:

import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';

const body = {
  username: 'alex',
  age: 30
};

const dto = plainToInstance(CreateUserDto, body);

validate(dto).then(errors => {
  console.log(errors);
});

Без преобразования декораторы не будут корректно работать, так как отсутствуют метаданные класса.

Вложенные DTO и рекурсивная валидация

DTO часто содержит вложенные структуры. Для их проверки используется комбинация @ValidateNested и @Type:

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

class ProfileDto {
  @IsString()
  city: string;
}

class CreateUserDto {
  @IsString()
  username: string;

  @ValidateNested()
  @Type(() => ProfileDto)
  profile: ProfileDto;
}

Без @Type вложенный объект не будет преобразован в экземпляр класса, и валидация не выполнится.

Работа с массивами DTO

Для массивов объектов применяется комбинация @IsArray и @ValidateNested({ each: true }):

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

class RoleDto {
  name: string;
}

class UserDto {
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => RoleDto)
  roles: RoleDto[];
}

Параметр each: true включает проверку каждого элемента массива отдельно.

Условная валидация через группы

Группы позволяют включать или отключать проверки в зависимости от контекста:

import { IsString } from 'class-validator';

class UpdateUserDto {
  @IsString({ groups: ['update'] })
  username: string;
}

При вызове валидации указывается группа:

validate(dto, { groups: ['update'] });

Механизм групп используется при различии сценариев:

  • создание объекта;
  • частичное обновление;
  • административные операции.

Опциональные поля и частичные DTO

Для частичных обновлений применяются @IsOptional:

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

class UpdateUserDto {
  @IsOptional()
  @IsString()
  username?: string;
}

Если поле отсутствует, остальные правила не применяются.

Ограничение входных данных (whitelist)

Валидация DTO часто используется совместно с фильтрацией лишних полей:

validate(dto, {
  whitelist: true,
  forbidNonWhitelisted: true
});

Поведение:

  • whitelist: true — удаляет поля, не описанные в DTO;
  • forbidNonWhitelisted: true — выбрасывает ошибку при наличии лишних полей.

Этот механизм защищает API от неожиданных входных данных.

Кастомные валидаторы

Когда встроенных проверок недостаточно, создаются собственные правила через ValidatorConstraint:

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments
} from 'class-validator';

@ValidatorConstraint({ name: 'isEven', async: false })
export class IsEvenConstraint implements ValidatorConstraintInterface {
  validate(value: number) {
    return value % 2 === 0;
  }

  defaultMessage(args: ValidationArguments) {
    return `${args.property} must be an even number`;
  }
}

Использование в DTO:

import { Validate } from 'class-validator';

class NumberDto {
  @Validate(IsEvenConstraint)
  value: number;
}

Кастомные валидаторы позволяют внедрять бизнес-правила напрямую в слой DTO.

Асинхронная валидация

Некоторые проверки требуют обращения к внешним системам (например, проверка уникальности пользователя). Для этого валидатор может быть асинхронным:

@ValidatorConstraint({ async: true })
class IsUserExistsConstraint {
  async validate(username: string) {
    const user = await database.findUser(username);
    return !user;
  }
}

Асинхронные проверки увеличивают гибкость, но требуют аккуратного управления производительностью.

Ошибки валидации и их структура

Результат валидации представляет собой дерево ошибок:

  • свойство (property);
  • ограничения (constraints);
  • вложенные ошибки (children).

Пример обработки:

validate(dto).then(errors => {
  errors.forEach(error => {
    console.log(error.property);
    console.log(error.constraints);
  });
});

При вложенных DTO структура ошибок становится иерархической.

Комбинирование декораторов

В реальных DTO декораторы часто комбинируются:

class ProductDto {
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  name: string;

  @IsNumber()
  @Min(0)
  price: number;

  @IsOptional()
  @IsString()
  description?: string;
}

Порядок декораторов не влияет на результат, так как они накапливают метаданные.

Типичные ошибки при использовании DTO-валидации

На практике часто возникают следующие проблемы:

  • отсутствие class-transformer при работе с plain objects;
  • забытый @Type для вложенных структур;
  • отсутствие whitelist, приводящее к “грязным” данным;
  • некорректное использование any в DTO;
  • смешивание DTO и доменных моделей.

Каждая из этих ошибок приводит к ослаблению гарантий типизации и валидации.

Производительность и масштабирование валидации

При большом количестве DTO и сложных вложенных структурах важно учитывать:

  • стоимость рекурсивной валидации;
  • накладные расходы асинхронных проверок;
  • частоту создания экземпляров классов;
  • глубину вложенности объектов.

Оптимизация обычно достигается за счёт ограничения глубины DTO и минимизации внешних вызовов в кастомных валидаторах.