Опирается на правила: R-VLD-XF-1R-VLD-XF-2 и R-VLD-XF-X1R-VLD-XF-X2 из Validation Style Guide → раздел 5. Cross-field validation.

Важно знать

  • Cross-field = правило с 2+ полями одного объекта: start ≤ end, password === passwordConfirm, amount ≤ creditLimit.
  • Реализуется как class-level constraint: registerDecorator с target: Function вместо propertyName. Применяется к классу (@DateRange() над class).
  • ValidationArguments.object содержит весь DTO — можно читать все поля.
  • Прицеп ошибки к полю — через addPropertyNode('start').addConstraintViolation() в ConstraintValidatorContext. Без этого ошибка прикрепляется к объекту целиком, field в violations пустой.
  • Имя — описывает правило, не объект: @DateRange, @PasswordsMatch, @AmountWithinLimit. Не @OrderRequestValid.
  • Cross-field в Handler перед dispatch — антипаттерн. Это контракт DTO, а не бизнес-правило.

Cross-field — узкая категория: стандартный class-validator проверяет поля по одному. Когда правило связывает несколько полей, нужен class-level constraint с говорящим именем и явным прицепом ошибки. Раскрытие раздела 5 гайда.

Class-level constraint: структура

R-VLD-XF-1: декоратор применяется к классу, ValidationArguments.object — полный DTO.

// common/validation/date-range.ts
import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments,
  ValidationOptions,
  registerDecorator,
} from 'class-validator';

@ValidatorConstraint({ name: 'dateRange', async: false })
export class DateRangeConstraint implements ValidatorConstraintInterface {
  validate(_value: unknown, args: ValidationArguments): boolean {
    const obj = args.object as { start: string; end: string };
    if (!obj.start || !obj.end) return true;   // null-проверка — на отдельных @IsISO8601()
    return obj.end >= obj.start;
  }

  defaultMessage(): string {
    return 'Дата окончания раньше даты начала';
  }
}

export function DateRange(options?: ValidationOptions) {
  return (target: Function) =>
    registerDecorator({
      target,
      propertyName: undefined as never,   // class-level: нет привязки к полю
      options,
      validator: DateRangeConstraint,
    });
}

Применение:

@DateRange()
export class PeriodRequest {
  @IsISO8601()
  start: string;

  @IsISO8601()
  end: string;
}

ValidationPipe прогонит field-level декораторы (@IsISO8601) и class-level (@DateRange) автоматически при наличии глобального pipe.

Прицеп ошибки к полю

R-VLD-XF-1 подразумевает — addPropertyNode обязателен для понятного UX.

validate(_value: unknown, args: ValidationArguments): boolean {
  const obj = args.object as { start: string; end: string };
  if (!obj.start || !obj.end) return true;
  if (obj.end >= obj.start) return true;

  // Прицепляем к конкретному полю
  const ctx = args.contexts?.validatorConstraintContext;
  if (ctx) {
    ctx.disableDefaultConstraintViolation();
    ctx.buildConstraintViolationWithTemplate(this.defaultMessage())
      .addPropertyNode('start')
      .addConstraintViolation();
  }
  return false;
}

Без addPropertyNode в violations появится { "field": "", "message": "..." } — фронту негде показывать ошибку. С addPropertyNode('start'){ "field": "start", "message": "Дата окончания раньше даты начала" }.

На практике ValidationArguments передаётся через стандартный API class-validator — ConstraintValidatorContext доступен через тип ValidationArguments:

validate(_value: unknown, { object, constraints }: ValidationArguments): boolean {
  const { start, end } = object as PeriodRequest;
  if (!start || !end) return true;
  return end >= start;
}

// Чтобы добавить propertyNode — используем buildConstraintViolationWithTemplate
// непосредственно в validate через второй аргумент context:
validate(_v: unknown, args: ValidationArguments): boolean {
  const o = args.object as { start: string; end: string };
  if (!o.start || !o.end) return true;
  if (o.end >= o.start) return true;
  (args as any).context?.buildConstraintViolationWithTemplate(this.defaultMessage())
    .addPropertyNode('start').addConstraintViolation();
  return false;
}

Имя описывает правило

R-VLD-XF-2: имя — что проверяется, не где применяется.

// ХОРОШО
@DateRange()
@PasswordsMatch()
@AmountWithinLimit()

// ПЛОХО
@OrderFilterValid()       // что именно валидируется?
@PeriodCheck()            // глагол
@RequestValidation()      // тавтология

Правильное имя позволяет переиспользовать constraint на любых DTO с той же семантикой.

Примеры из доменов Order/Product/Customer/Sber:

// Диапазон дат отчётного периода — Sber analytics
@DateRange()
export class SberReportRequest {
  @IsISO8601() startDate: string;
  @IsISO8601() endDate: string;
}

// Подтверждение пароля — Customer registration
@PasswordsMatch()
export class ChangePasswordRequest {
  @IsString() @MinLength(8) newPassword: string;
  @IsString() passwordConfirm: string;
}

// Лимит суммы — Order с кредитным лимитом
@AmountWithinCreditLimit()
export class CreditOrderRequest {
  @IsString() @Matches(/^\d+\.\d{2}$/) amount: string;
  @IsString() @Matches(/^\d+\.\d{2}$/) creditLimit: string;
}

Каждый — отдельный файл в common/validation/, переиспользуется без копипасты.

Пример: @PasswordsMatch

// common/validation/passwords-match.ts
@ValidatorConstraint({ name: 'passwordsMatch', async: false })
export class PasswordsMatchConstraint implements ValidatorConstraintInterface {
  validate(_: unknown, args: ValidationArguments): boolean {
    const { newPassword, passwordConfirm } = args.object as {
      newPassword: string;
      passwordConfirm: string;
    };
    if (!newPassword || !passwordConfirm) return true;
    return newPassword === passwordConfirm;
  }

  defaultMessage(): string {
    return 'Пароли не совпадают';
  }
}

export function PasswordsMatch(options?: ValidationOptions) {
  return (target: Function) =>
    registerDecorator({
      target,
      propertyName: undefined as never,
      options,
      validator: PasswordsMatchConstraint,
    });
}

// Применение
@PasswordsMatch()
export class ResetPasswordRequest {
  @IsString()
  @MinLength(8, { message: 'Пароль не менее {min} символов' })
  newPassword: string;

  @IsString()
  passwordConfirm: string;
}

Cross-field в Handler — нет

R-VLD-XF-X2: Handler — оркестратор, не валидатор контракта.

// ПЛОХО
@Injectable()
class GetOrdersHandler {
  async handle(query: GetOrders): Promise<Order[]> {
    if (query.dateFrom && query.dateTo && query.dateFrom > query.dateTo) {
      throw new Error('dateFrom позже dateTo');   // → 500 без структурированных violations
    }
    // ...
  }
}

// ХОРОШО — правило на DTO
@DateRange()
export class GetOrdersRequest {
  @IsOptional() @IsISO8601() dateFrom?: string;
  @IsOptional() @IsISO8601() dateTo?: string;
}

«dateFromdateTo» — правило входного контракта. Не зависит от агрегатов, от состояния БД, от бизнес-фазы. Место — на DTO, проверяется ValidationPipe до Handler. throw new Error() в Handler даст 500 вместо 400 без явного ExceptionFilter.

Что запрещено

АнтипаттернПравилоЧто взамен
Одноразовое правило inline, если встречается в нескольких DTOR-VLD-XF-X1Именованный constraint в common/validation/
Cross-field в Handler перед dispatchR-VLD-XF-X2Class-level @<Rule>() на DTO
Ошибка без addPropertyNodefield: "" в violationsR-VLD-XF-1addPropertyNode('fieldName').addConstraintViolation()
Имя @<DtoName>Valid (@OrderRequestValid)R-VLD-XF-2Имя по правилу: @DateRange, @PasswordsMatch

Куда дальше

  • Validation → раздел 5. Cross-field validation — нормативные формулировки R-VLD-XF-*.
  • Custom constraints — field-level constraint, встречается чаще.
  • Где валидировать — почему cross-field на DTO, не в Handler.
  • Validation groups — механизм, который часто путают с cross-field.
  • Messages и i18n — как defaultMessage() попадает в ответ.