Валидация с зависимостями между полями

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

Основной особенностью подобных сценариев является переход от декларативных одиночных декораторов к контекстным проверкам, использующим объект целиком через функции валидаторов или пользовательские constraint-классы.


Контекстная условная валидация через @ValidateIf

Декоратор @ValidateIf управляет тем, будет ли выполняться последующая валидация свойства. Условие получает доступ к объекту целиком, что позволяет связывать поля между собой.

Типичный механизм работы:

  • функция принимает текущий объект
  • возвращается true или false
  • при false остальные валидаторы свойства не выполняются
import { ValidateIf, IsNotEmpty, IsEmail } from 'class-validator';

class UserDto {
  role: string;

  @ValidateIf(o => o.role === 'admin')
  @IsEmail()
  email: string;
}

В данном примере поле email становится обязательным и проверяемым только при значении role = 'admin'.

Такой подход часто используется для:

  • условных форм
  • частично заполняемых DTO
  • различий между ролями пользователей

Сравнение полей через пользовательские валидаторы

Когда требуется сравнение значений двух свойств, встроенных декораторов становится недостаточно. Основной инструмент — создание собственного constraint.

Механизм состоит из двух частей:

  • класс, реализующий ValidatorConstraintInterface
  • декоратор, регистрирующий правило

Базовая структура кастомного constraint

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

@ValidatorConstraint({ name: 'Match', async: false })
export class MatchConstraint implements ValidatorConstraintInterface {
  validate(value: any, args: ValidationArguments) {
    const [relatedPropertyName] = args.constraints;
    const relatedValue = (args.object as any)[relatedPropertyName];

    return value === relatedValue;
  }

  defaultMessage(args: ValidationArguments) {
    const [relatedPropertyName] = args.constraints;
    return `Значение должно совпадать с ${relatedPropertyName}`;
  }
}

Декоратор-обёртка:

import { registerDecorator, ValidationOptions } from 'class-validator';
import { MatchConstraint } from './match.constraint';

export function Match(property: string, options?: ValidationOptions) {
  return function (object: any, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName,
      options,
      constraints: [property],
      validator: MatchConstraint,
    });
  };
}

Подтверждение пароля

Наиболее распространённый кейс зависимой валидации — проверка совпадения пароля и его подтверждения.

import { IsString, MinLength } from 'class-validator';
import { Match } from './match.decorator';

class RegisterDto {
  @IsString()
  @MinLength(8)
  password: string;

  @IsString()
  @Match('password', { message: 'Пароли не совпадают' })
  confirmPassword: string;
}

Здесь значение confirmPassword сравнивается с password через доступ к объекту в ValidationArguments.


Сравнение дат: начало и конец периода

Зависимости между временными полями требуют сравнения значений одного типа.

Пример constraint для проверки, что конечная дата не меньше начальной:

@ValidatorConstraint({ name: 'IsAfter', async: false })
export class IsAfterConstraint implements ValidatorConstraintInterface {
  validate(value: any, args: ValidationArguments) {
    const [relatedPropertyName] = args.constraints;
    const relatedValue = (args.object as any)[relatedPropertyName];

    if (!value || !relatedValue) return true;

    const start = new Date(relatedValue);
    const end = new Date(value);

    return end >= start;
  }

  defaultMessage() {
    return 'Конечная дата должна быть больше или равна начальной';
  }
}

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

class PeriodDto {
  startDate: string;

  @Validate(IsAfterConstraint, ['startDate'])
  endDate: string;
}

Условная валидация на основе нескольких полей

Зависимости могут включать более сложные условия, чем простое сравнение. Например, проверка обязательности поля в зависимости от комбинации значений.

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

class AccessDto {
  type: string;

  @ValidateIf(o => o.type === 'token' || o.type === 'apikey')
  @IsString()
  credentials: string;
}

Здесь поле credentials становится обязательным при нескольких возможных значениях type.


Логика «хотя бы одно поле»

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

@ValidatorConstraint({ name: 'AtLeastOne', async: false })
export class AtLeastOneConstraint implements ValidatorConstraintInterface {
  validate(_: any, args: ValidationArguments) {
    const fields = args.constraints;
    const object = args.object as any;

    return fields.some(field => {
      const value = object[field];
      return value !== null && value !== undefined && value !== '';
    });
  }

  defaultMessage(args: ValidationArguments) {
    return `Необходимо заполнить хотя бы одно из полей: ${args.constraints.join(', ')}`;
  }
}

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

class ContactDto {
  email?: string;
  phone?: string;

  @Validate(AtLeastOneConstraint, ['email', 'phone'])
  _check: string;
}

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


Зависимости с вложенными объектами

При работе с вложенными структурами зависимые проверки часто распространяются на вложенные DTO.

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

class AddressDto {
  country: string;
  city: string;
}

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

  role: string;
}

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


Доступ к родительскому объекту в глубокой структуре

ValidationArguments.object всегда содержит корневой объект, даже при вложенной валидации. Это позволяет реализовывать межуровневые зависимости:

@ValidatorConstraint({ name: 'CityDependsOnCountry', async: false })
export class CityDependsOnCountry implements ValidatorConstraintInterface {
  validate(value: any, args: ValidationArguments) {
    const object = args.object as any;

    if (object.address?.country === 'Kazakhstan') {
      return value === 'Karaganda' || value === 'Almaty';
    }

    return true;
  }
}

Комбинация @ValidateIf и кастомных constraint

Сложные правила часто строятся через комбинацию подходов:

  • @ValidateIf отключает ненужные проверки
  • кастомные constraint обеспечивают точную логику сравнения
  • стандартные декораторы ограничивают формат данных
class PaymentDto {
  method: string;

  @ValidateIf(o => o.method === 'card')
  @IsString()
  cardNumber: string;

  @ValidateIf(o => o.method === 'card')
  @IsString()
  expiryDate: string;
}

Типичные особенности поведения зависимых валидаторов

  • порядок выполнения не гарантирует последовательность между свойствами
  • каждый constraint получает доступ к объекту целиком, а не только к текущему полю
  • повторное использование валидаторов требует отсутствия побочных эффектов
  • асинхронные проверки должны явно указывать async: true в ValidatorConstraint

Ограничения и архитектурные нюансы

При проектировании зависимой валидации важно учитывать, что:

  • логика не должна зависеть от внешнего состояния без явного асинхронного валидатора
  • сложные взаимозависимости увеличивают связность DTO
  • чрезмерное использование @ValidateIf усложняет трассировку правил
  • вложенные условия лучше инкапсулировать в отдельные constraint-классы

Композиция правил на уровне объекта

Когда требуется объединение нескольких зависимостей, валидаторы могут применяться одновременно к одному полю или набору полей, формируя композицию ограничений:

class TaskDto {
  status: string;
  priority: number;

  @ValidateIf(o => o.status === 'open')
  @Validate(IsAfterConstraint, ['createdAt'])
  dueDate: string;
}

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