Опирается на правила:
R-VLD-XF-1…R-VLD-XF-2иR-VLD-XF-X1…R-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 — можно читать все поля.- Прицеп ошибки к полю в class-validator невозможен через
addPropertyNode— такого API нет. Class-level constraint даётproperty: undefinedв violations; для точного UX используйте field-level constraint, который читает соседнее поле черезargs.object.- Имя — описывает правило, не объект:
@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.
Прицеп ошибки к полю
Class-validator не предоставляет addPropertyNode или buildConstraintViolationWithTemplate — это Jakarta Validation API (Java). В ValidationArguments нет validatorConstraintContext, нет ConstraintValidatorContext. Class-level constraint всегда даёт property: undefined в violations.
Реальные варианты при необходимости точного указания поля:
Вариант 1 — field-level constraint, читающий соседнее поле через args.object (точный property в violation):
// common/validation/not-before.ts
@ValidatorConstraint({ name: 'notBefore', async: false })
export class NotBeforeConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments): boolean {
const start = (args.object as { start: string }).start;
if (!start || !value) return true;
return (value as string) >= start;
}
defaultMessage(): string {
return 'Дата окончания раньше даты начала';
}
}
export function NotBefore(options?: ValidationOptions) {
return (object: object, propertyName: string) =>
registerDecorator({
target: object.constructor,
propertyName,
options,
validator: NotBeforeConstraint,
});
}
Применение — @NotBefore() на поле end; property: "end" попадает в violation:
export class PeriodRequest {
@IsISO8601()
start: string;
@IsISO8601()
@NotBefore()
end: string;
}
Вариант 2 — class-level constraint (как в первом примере) — property: undefined в violations; фронт показывает ошибку на уровне формы, не отдельного поля. Уместно, когда неясно, какое поле «виновато»:
{ "field": "", "message": "Дата окончания раньше даты начала" }
Имя описывает правило
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: 'Пароль не менее $constraint1 символов' })
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;
}
«dateFrom ≤ dateTo» — правило входного контракта. Не зависит от агрегатов, от состояния БД, от бизнес-фазы. Место — на DTO, проверяется ValidationPipe до Handler. throw new Error() в Handler даст 500 вместо 400 без явного ExceptionFilter.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Одноразовое правило inline, если встречается в нескольких DTO | R-VLD-XF-X1 | Именованный constraint в common/validation/ |
Cross-field в Handler перед dispatch | R-VLD-XF-X2 | Class-level @<Rule>() на DTO |
Class-level constraint → field: "" в violations, нужен точный field | R-VLD-XF-1 | Field-level constraint с чтением соседнего поля через args.object |
Имя @<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()попадает в ответ.