Валидация вложенных объектов с @ValidateNested

При работе с объектными моделями данных в реальных приложениях редко встречаются плоские структуры. Чаще всего данные имеют иерархию: пользователь содержит профиль, профиль — адрес, заказ содержит список товаров и т.д. Простая валидация примитивных полей перестаёт быть достаточной, поскольку необходимо проверять корректность не только верхнего уровня объекта, но и всех вложенных сущностей.

В библиотеке class-validator для этих целей используется декоратор @ValidateNested, который позволяет рекурсивно применять правила валидации к вложенным объектам и массивам объектов.


Проблема вложенной валидации

Без специальной настройки class-validator не выполняет автоматическую проверку вложенных структур. Рассмотрим типичный пример:

class Address {
  street: string;

  city: string;

  zip: string;
}

class User {
  name: string;

  address: Address;
}

Если применить валидацию к User, то проверка затронет только поле name. Поле address будет проигнорировано, даже если внутри него находятся некорректные значения.

Причина в том, что библиотека не создает экземпляры вложенных классов автоматически и не знает, что поле address должно валидироваться как отдельная сущность.


Основной механизм @ValidateNested

Декоратор @ValidateNested указывает библиотеке, что поле содержит вложенный объект или массив объектов, которые также должны быть валидированы.

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

class User {
  @ValidateNested()
  @Type(() => Address)
  address: Address;
}

Ключевой момент заключается в сочетании двух инструментов:

  • @ValidateNested() — включает рекурсивную валидацию
  • @Type(() => Class) из class-transformer — обеспечивает корректное преобразование plain-объекта в экземпляр класса

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


Полная конфигурация вложенного класса

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

import { Type } from 'class-transformer';

class Address {
  @IsString()
  street: string;

  @IsString()
  city: string;

  @Length(5, 10)
  zip: string;
}

class User {
  @IsString()
  name: string;

  @ValidateNested()
  @Type(() => Address)
  address: Address;
}

При такой конфигурации валидация затронет:

  • поле name в User
  • все поля внутри Address

Важность class-transformer в цепочке валидации

Валидация вложенных объектов в class-validator напрямую зависит от преобразования данных.

Входные данные обычно приходят в виде plain object:

const payload = {
  name: 'Alex',
  address: {
    street: 'Main',
    city: 'NY',
    zip: '12345'
  }
};

После преобразования:

const user = plainToInstance(User, payload);

только после этого @ValidateNested способен корректно пройти по дереву объектов.

Без этого шага address останется обычным объектом, и декораторы внутри Address не будут активированы.


Валидация массивов вложенных объектов

Частый сценарий — массив вложенных сущностей. Например, список адресов пользователя:

class User {
  @ValidateNested({ each: true })
  @Type(() => Address)
  addresses: Address[];
}

Ключевой параметр each: true

Он определяет, что:

  • валидация применяется к каждому элементу массива
  • каждый элемент массива рассматривается как отдельный объект класса Address

Пример с массивом сложных объектов

class Product {
  @IsString()
  title: string;

  @IsNumber()
  price: number;
}

class Order {
  @ValidateNested({ each: true })
  @Type(() => Product)
  products: Product[];
}

Поведение:

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

Глубокая вложенность объектов

Структуры могут иметь несколько уровней вложенности:

class Country {
  @IsString()
  name: string;
}

class Address {
  @ValidateNested()
  @Type(() => Country)
  country: Country;

  @IsString()
  city: string;
}

class User {
  @ValidateNested()
  @Type(() => Address)
  address: Address;
}

В этом случае валидация происходит рекурсивно:

  • User.address
  • Address.country

Каждый уровень требует собственного @ValidateNested и @Type.


Типичные ошибки при использовании @ValidateNested

Отсутствие @Type

Наиболее частая ошибка:

class User {
  @ValidateNested()
  address: Address;
}

Результат:

  • вложенная валидация не выполняется
  • address не преобразуется в класс

Отсутствие @ValidateNested

class User {
  @Type(() => Address)
  address: Address;
}

Результат:

  • объект преобразуется в класс
  • но правила валидации внутри Address не применяются

Неправильное использование each

@ValidateNested()
@Type(() => Address)
addresses: Address[];

Результат:

  • массив рассматривается как единый объект
  • элементы не валидируются отдельно

Поведение при ошибках вложенной валидации

Ошибки формируются в виде дерева:

[
  {
    property: 'address',
    children: [
      {
        property: 'zip',
        constraints: {
          length: 'zip must be longer than or equal to 5 characters'
        }
      }
    ]
  }
]

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


Валидация частично заполненных вложенных объектов

Если вложенный объект может быть опциональным, применяется комбинация декораторов:

import { IsOptional } from 'class-validator';

class User {
  @IsOptional()
  @ValidateNested()
  @Type(() => Address)
  address?: Address;
}

В этом случае:

  • отсутствие address не вызывает ошибок
  • при наличии объекта запускается полная валидация

Комбинация с другими декораторами

@ValidateNested часто используется вместе с:

  • @IsArray() — для явного указания массива
  • @IsOptional() — для необязательных полей
  • @ArrayMinSize() / @ArrayMaxSize() — для ограничения количества элементов

Пример:

class Order {
  @IsArray()
  @ArrayMinSize(1)
  @ValidateNested({ each: true })
  @Type(() => Product)
  products: Product[];
}

Рекурсивные структуры данных

@ValidateNested поддерживает рекурсивные модели, например дерево категорий:

class Category {
  @IsString()
  name: string;

  @ValidateNested({ each: true })
  @Type(() => Category)
  children: Category[];
}

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


Производительность при глубокой валидации

При большом количестве уровней вложенности и массивов объектов возрастает стоимость валидации:

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

В сложных структурах это становится заметным фактором, особенно при массовой обработке данных.


Поведение с частично некорректными структурами

Если часть вложенных объектов валидна, а часть нет:

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

Это важно при обработке массивов, где требуется частичная обработка данных без полного отказа всей операции.


Использование с DTO-структурами

В архитектуре на основе DTO вложенная валидация становится стандартом:

class CreateUserDto {
  @IsString()
  name: string;

  @ValidateNested()
  @Type(() => CreateAddressDto)
  address: CreateAddressDto;
}

Такой подход позволяет:

  • изолировать правила валидации по слоям
  • переиспользовать вложенные DTO
  • поддерживать единообразную структуру данных

Итоговые принципы применения

Использование @ValidateNested требует соблюдения нескольких обязательных условий:

  • каждый вложенный класс должен иметь собственные декораторы валидации
  • для каждого вложенного объекта обязателен @Type
  • для массивов необходимо использовать { each: true }
  • глубина вложенности требует повторения паттерна на каждом уровне

Без соблюдения этих условий вложенная валидация либо не сработает, либо будет работать частично, что приведёт к некорректной проверке данных в сложных структурах.