Баланс между строгостью и гибкостью

Валидация данных в прикладных системах на JavaScript редко сводится к простому «разрешить или запретить». Почти всегда требуется одновременно защищать систему от некорректного ввода и не ломать удобство интеграции с внешними клиентами. Библиотека Class-validator занимает промежуточную позицию между жёсткой схемной валидацией и полностью динамическим подходом, предоставляя инструменты для точной настройки уровня строгости на уровне каждого поля.

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


Строгая валидация как базовый контракт

Строгая валидация рассматривает входные данные как фиксированный контракт. Любое отклонение от него считается ошибкой.

Ключевые инструменты Class-validator для строгого подхода:

  • @IsDefined() — поле обязательно должно присутствовать
  • @IsNotEmpty() — значение не может быть пустым
  • @IsString(), @IsNumber(), @IsBoolean() — строгая типизация
  • @ValidateNested() — строгая проверка вложенных объектов

Пример строгого DTO:

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

class CreateUserDto {
  @IsDefined()
  @IsString()
  name: string;

  @IsDefined()
  @IsInt()
  @Min(0)
  age: number;

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

class AddressDto {
  @IsDefined()
  @IsString()
  city: string;
}

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


Гибкость как инструмент адаптации к реальности

В реальных API данные часто приходят частично: формы редактирования, PATCH-запросы, интеграции с внешними сервисами. Здесь строгая модель становится ограничением.

Гибкость в Class-validator достигается через:

  • @IsOptional() — разрешает отсутствие поля
  • @ValidateIf() — условная валидация
  • значения по умолчанию через трансформацию
  • частичную проверку DTO

Пример гибкого DTO:

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

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

  @IsOptional()
  @IsInt()
  @Min(0)
  age?: number;
}

Такой подход позволяет отправлять только изменяемые поля, не требуя полного объекта.


IsOptional и скрытая сложность частичных моделей

@IsOptional() не просто пропускает поле — он влияет на весь процесс валидации. Если значение undefined или null, цепочка валидаторов для этого поля не выполняется.

Это создаёт важный нюанс: поле становится не «необязательным», а «условно проверяемым».

Типичные ошибки:

  • ожидание, что @IsOptional() допускает пустую строку (это не так)
  • смешивание null и undefined без явной политики
  • отсутствие различия между «нет значения» и «пустое значение»

Для более строгого контроля часто комбинируют:

@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;

Такой набор означает: поле может отсутствовать, но если присутствует — не может быть пустым.


ValidateIf как механизм условной строгости

@ValidateIf() позволяет строить зависимые правила, где наличие или корректность одного поля влияет на другое.

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

class ProfileDto {
  @IsString()
  mode: 'simple' | 'advanced';

  @ValidateIf(o => o.mode === 'advanced')
  @IsString()
  advancedConfig: string;
}

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

Это особенно важно для:

  • конфигурационных API
  • форм с переключаемыми режимами
  • интеграций с внешними системами с различными контрактами

PATCH, PUT и разная степень строгости

Разделение DTO по типу операции — один из ключевых способов управления балансом.

PUT (полная замена)

class ReplaceUserDto {
  @IsString()
  name: string;

  @IsInt()
  age: number;
}

Строгая модель: все поля обязательны.

PATCH (частичное обновление)

class PatchUserDto {
  @IsOptional()
  @IsString()
  name?: string;

  @IsOptional()
  @IsInt()
  age?: number;
}

Гибкая модель: любое поле может быть пропущено.

Разделение DTO позволяет не усложнять одну модель множеством условных правил.


Группы валидации как инструмент переключения режимов

Class-validator поддерживает группы, позволяющие применять разные правила в зависимости от контекста.

import { IsString } from 'class-validator';

class UserDto {
  @IsString({ groups: ['create'] })
  name: string;

  @IsString({ groups: ['update'] })
  id: string;
}

При вызове валидации можно выбирать набор правил:

  • create
  • update
  • admin
  • public

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


Whitelist и forbidNonWhitelisted как строгий фильтр структуры

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

Настройки:

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

Пример:

import { validate } from 'class-validator';

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

Разница:

  • whitelist — мягкая строгость (очистка)
  • forbidNonWhitelisted — жёсткая строгость (ошибка)

В API с внешними клиентами чаще используют whitelist, чтобы не ломать интеграции при расширении схемы.


Частичная валидация и трансформация данных

В реальных приложениях данные часто приходят в «сыром» виде. Здесь важна связка class-transformer и Class-validator.

import { plainToInstance } from 'class-transformer';

const dto = plainToInstance(UpdateUserDto, body);

Дополнительные настройки:

  • skipMissingProperties
  • enableImplicitConversion

Эти параметры влияют на степень строгости:

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

Вложенные структуры и накопление строгости

Вложенные DTO часто усиливают эффект строгой модели.

class CompanyDto {
  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

Если не настроить гибкость правильно:

  • @ValidateNested() требует существования объекта
  • отсутствие @IsOptional() приводит к обязательности всего дерева

Гибкий вариант:

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

Здесь баланс достигается на уровне вложенности.


Кастомные валидаторы как точка расширения

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

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

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

Кастомная валидация позволяет:

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

Но чрезмерное использование приводит к скрытой бизнес-логике вне сервисного слоя.


Типичные перекосы в сторону излишней строгости

Слишком жёсткая модель приводит к:

  • невозможности эволюции API без breaking changes
  • дублированию DTO под мелкие отличия
  • усложнению клиентской логики

Признаки:

  • множество почти одинаковых DTO
  • отсутствие @IsOptional() там, где логически допустимы частичные данные
  • избыточные @IsNotEmpty() без бизнес-смысла

Типичные перекосы в сторону излишней гибкости

Чрезмерная гибкость проявляется иначе:

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

Последствия:

  • трудно отлавливаемые ошибки в рантайме
  • нестабильные контракты API
  • зависимость логики от «плавающих» данных

Практика балансировки модели DTO

Рабочий подход обычно строится вокруг разделения уровней строгости:

  • доменные операции — строгие DTO
  • API входы — умеренно гибкие DTO
  • PATCH-операции — максимально гибкие DTO
  • внешние интеграции — жёсткий whitelist + адаптер

Структурное разделение позволяет избегать попытки одной моделью покрыть все сценарии.


Эволюция схем и обратная совместимость

Любая система валидации живёт в условиях изменения данных.

Поддержание баланса достигается через:

  • добавление новых полей с @IsOptional()
  • сохранение старых полей без удаления
  • использование групп для альтернативных версий правил
  • разделение DTO по версиям API

Пример версионирования:

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

class UserV2Dto {
  @IsString()
  name: string;

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

Контекст выполнения как фактор строгости

Степень проверки часто зависит не от DTO, а от контекста:

  • создание сущности требует максимальной строгости
  • обновление допускает частичность
  • внутренние сервисы могут использовать более строгие правила, чем публичный API

Class-validator позволяет реализовать это через:

  • группы
  • условные валидаторы
  • разные DTO
  • настройки validate()

Системный подход к балансу

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

  • структура объекта
  • обязательность полей
  • условная логика
  • глубина вложенности
  • стратегия обработки лишних данных
  • контекст использования DTO