Опирается на правила:
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 — можно читать все поля.- Прицеп ошибки к полю — через
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;
}
«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 |
Ошибка без addPropertyNode — field: "" в violations | R-VLD-XF-1 | addPropertyNode('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()попадает в ответ.