Кросс-полевая валидация

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

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


Контекст объекта в процессе валидации

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

Внутри ValidationArguments доступны ключевые элементы:

  • value — значение текущего поля
  • object — весь валидируемый экземпляр класса
  • property — имя текущего свойства
  • constraints — дополнительные параметры, переданные в декоратор
  • targetName — имя класса

Именно object становится основой кросс-полевой логики.


Базовый механизм пользовательского валидатора

Кросс-полевая проверка реализуется через ValidatorConstraint и интерфейс ValidatorConstraintInterface.

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

@ValidatorConstraint({ name: "matchFields", async: false })
class MatchFieldsConstraint implements ValidatorConstraintInterface {
  validate(value: any, args: ValidationArguments) {
    const object = args.object as any;
    const [relatedPropertyName] = args.constraints;

    return value === object[relatedPropertyName];
  }

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

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

import { Validate } from "class-validator";

class UserDto {
  password: string;

  @Validate(MatchFieldsConstraint, ["password"])
  confirmPassword: string;
}

Здесь логика сравнивает два поля одного объекта, что является типичным примером кросс-полевой валидации.


Сравнение нескольких полей в рамках одного правила

Кросс-полевая проверка часто включает более сложные зависимости, чем простое равенство.

Проверка диапазона дат

@ValidatorConstraint({ name: "dateRange", async: false })
class DateRangeConstraint implements ValidatorConstraintInterface {
  validate(_: any, args: ValidationArguments) {
    const obj = args.object as any;

    const start = new Date(obj.startDate);
    const end = new Date(obj.endDate);

    return start <= end;
  }

  defaultMessage() {
    return "startDate должна быть меньше или равна endDate";
  }
}
class EventDto {
  startDate: string;

  @Validate(DateRangeConstraint)
  endDate: string;
}

В данном случае проверка закреплена за одним из полей, но использует сразу оба значения объекта.


Использование constraints для передачи зависимых полей

constraints позволяет параметризовать валидатор именами полей, что делает его универсальным.

@ValidatorConstraint({ name: "greaterThan", async: false })
class GreaterThanConstraint implements ValidatorConstraintInterface {
  validate(value: number, args: ValidationArguments) {
    const [relatedPropertyName] = args.constraints;
    const object = args.object as any;

    return typeof value === "number" &&
           typeof object[relatedPropertyName] === "number" &&
           value > object[relatedPropertyName];
  }

  defaultMessage(args: ValidationArguments) {
    const [relatedPropertyName] = args.constraints;
    return `${args.property} должно быть больше ${relatedPropertyName}`;
  }
}
class ProductDto {
  minPrice: number;

  @Validate(GreaterThanConstraint, ["minPrice"])
  maxPrice: number;
}

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


Кросс-полевая логика через registerDecorator

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

import {
  registerDecorator,
  ValidationOptions,
  ValidationArguments,
} from "class-validator";

function IsEqualTo(property: string, options?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: "isEqualTo",
      target: object.constructor,
      propertyName,
      constraints: [property],
      options,
      validator: {
        validate(value: any, args: ValidationArguments) {
          const [relatedPropertyName] = args.constraints;
          const obj = args.object as any;
          return value === obj[relatedPropertyName];
        },
      },
    });
  };
}

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

class RegisterDto {
  password: string;

  @IsEqualTo("password")
  confirmPassword: string;
}

Условная кросс-полевая валидация

Часто проверка зависит от состояния нескольких полей одновременно.

@ValidatorConstraint({ name: "conditionalRequired", async: false })
class ConditionalRequired implements ValidatorConstraintInterface {
  validate(value: any, args: ValidationArguments) {
    const obj = args.object as any;
    const [flagProperty] = args.constraints;

    if (obj[flagProperty] === true) {
      return value !== null && value !== undefined && value !== "";
    }

    return true;
  }
}
class PaymentDto {
  isCard: boolean;

  @Validate(ConditionalRequired, ["isCard"])
  cardNumber: string;
}

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


Валидация вложенных структур и кросс-уровневые зависимости

Кросс-полевая проверка часто затрагивает вложенные DTO.

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

class Address {
  city: string;
  zip: string;
}

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

  country: string;
}

Пользовательский валидатор может ссылаться на вложенные данные:

@ValidatorConstraint({ name: "countryZipMatch", async: false })
class CountryZipMatch implements ValidatorConstraintInterface {
  validate(_: any, args: ValidationArguments) {
    const obj = args.object as any;

    if (obj.country === "US") {
      return /^[0-9]{5}$/.test(obj.address?.zip);
    }

    return true;
  }
}

Здесь проверка пересекает границу вложенного объекта, что расширяет понятие кросс-полевой логики до кросс-структурной.


Асинхронная кросс-полевая валидация

Некоторые зависимости требуют внешних данных: базы данных, API или других сервисов.

@ValidatorConstraint({ name: "uniquePair", async: true })
class UniquePairConstraint implements ValidatorConstraintInterface {
  async validate(_: any, args: ValidationArguments) {
    const obj = args.object as any;

    const email = obj.email;
    const companyId = obj.companyId;

    const exists = await fakeDatabaseCheck(email, companyId);

    return !exists;
  }
}
class EmployeeDto {
  email: string;
  companyId: number;

  @Validate(UniquePairConstraint)
  role: string;
}

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


Ошибки проектирования кросс-полевых правил

Жёсткая привязка к именам полей

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

args.constraints = ["password"];

При изменении структуры DTO логика перестаёт работать.

Смешивание бизнес-логики и валидации

Сложные вычисления внутри валидаторов ухудшают тестируемость и повторное использование.

Избыточная ответственность одного валидатора

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


Организация набора кросс-полевых правил

Практика разделения:

  • один валидатор — одна бизнес-связь
  • параметризация через constraints
  • использование registerDecorator для простых случаев
  • ValidatorConstraint для сложной логики

Использование ValidationArguments для расширенной логики

ValidationArguments позволяет реализовывать динамические проверки:

validate(value: any, args: ValidationArguments) {
  const obj = args.object as any;

  const dynamicKey = args.constraints[0];
  return value === obj[dynamicKey];
}

Также доступны сценарии, где логика зависит от нескольких параметров:

validate(value: any, args: ValidationArguments) {
  const [minKey, maxKey] = args.constraints;
  const obj = args.object as any;

  return obj[minKey] <= value && value <= obj[maxKey];
}

Сложные зависимости между несколькими полями

Кросс-полевая валидация может охватывать более двух полей одновременно.

@ValidatorConstraint({ name: "budgetConsistency", async: false })
class BudgetConsistency implements ValidatorConstraintInterface {
  validate(_: any, args: ValidationArguments) {
    const obj = args.object as any;

    const total = obj.totalBudget;
    const marketing = obj.marketingBudget;
    const dev = obj.devBudget;

    return marketing + dev <= total;
  }
}
class ProjectDto {
  totalBudget: number;
  marketingBudget: number;
  devBudget: number;

  @Validate(BudgetConsistency)
  status: string;
}

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

Важной особенностью является то, что object передаётся по ссылке. Это означает:

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

Композиция кросс-полевых правил

Несколько валидаторов могут применяться к одному полю:

class AccountDto {
  password: string;

  @Validate(MatchFieldsConstraint, ["password"])
  @Validate(ComplexPasswordConstraint)
  confirmPassword: string;
}

Композиция позволяет разделять:

  • синтаксические проверки
  • семантические связи между полями

Области применения кросс-полевой валидации

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

Архитектурные ограничения подхода

Кросс-полевая валидация в class-validator опирается на объектную модель DTO, что накладывает ограничения:

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