Кросс-полевая валидация применяется для проверки взаимосвязей между несколькими свойствами одного объекта или даже между вложенными структурами. В отличие от одиночных декораторов, работающих с одним полем, такой тип проверки опирается на контекст всего объекта, что позволяет выражать бизнес-правила уровня модели данных.
В библиотеке 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 позволяет параметризовать валидатор именами
полей, что делает его универсальным.
@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,
позволяющий создавать декларативные валидаторы без классов.
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 снижает читаемость.
Практика разделения:
constraintsregisterDecorator для простых
случаевValidatorConstraint для сложной логики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;
}
Композиция позволяет разделять:
Кросс-полевая валидация в class-validator опирается на объектную модель DTO, что накладывает ограничения: