В библиотеке class-validator пользовательские валидаторы строятся вокруг интерфейса ValidatorConstraintInterface и механизма регистрации через декоратор @ValidatorConstraint. Этот подход позволяет превращать произвольную бизнес-логику проверки в переиспользуемые классы, которые интегрируются в систему валидации на уровне метаданных.
Основная идея заключается в том, что валидатор оформляется как класс, помеченный специальным декоратором, после чего становится доступным для использования в декораторах полей моделей. Такой подход обеспечивает слабую связанность между схемой данных и логикой проверки, а также упрощает тестирование и масштабирование правил валидации.
Любой кастомный валидатор в class-validator представляет собой класс, реализующий интерфейс ValidatorConstraintInterface. Внутри определяются как минимум два метода:
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from 'class-validator';
@ValidatorConstraint()
class IsEvenConstraint implements ValidatorConstraintInterface {
validate(value: number, args: ValidationArguments) {
return typeof value === 'number' && value % 2 === 0;
}
defaultMessage(args: ValidationArguments) {
return `Значение $value не является чётным числом`;
}
}
Декоратор @ValidatorConstraint связывает класс с системой class-validator, регистрируя его как валидатор. После этого класс может быть использован в декораторе @Validate.
Декоратор @ValidatorConstraint выполняет регистрацию класса в глобальном реестре валидаторов библиотеки. При компиляции метаданных TypeScript сохраняется информация о том, что данный класс является constraint-валидатором.
Он принимает параметры конфигурации:
@ValidatorConstraint({ name: 'isEven', async: false })
class IsEvenConstraint implements ValidatorConstraintInterface {
validate(value: number) {
return value % 2 === 0;
}
defaultMessage() {
return 'Число должно быть чётным';
}
}
Указание имени критично при интеграции с системой DI и повторном использовании валидатора в различных контекстах. Без явного имени class-validator использует имя класса, что может привести к конфликтам при минификации или переименовании.
После регистрации класс применяется в моделях через декоратор @Validate. Он связывает конкретное поле с ранее определённым constraint-классом.
import { Validate } from 'class-validator';
class SampleDto {
@Validate(IsEvenConstraint)
value: number;
}
На этапе валидации class-validator создает экземпляр класса IsEvenConstraint (либо использует контейнер зависимостей, если он подключён) и вызывает метод validate.
Флаг async: true позволяет использовать промисы внутри метода validate. Это необходимо при проверках, зависящих от внешних источников данных: базы данных, API или кэша.
@ValidatorConstraint({ name: 'isUserExists', async: true })
class IsUserExistsConstraint implements ValidatorConstraintInterface {
async validate(userId: string) {
const user = await fakeDatabase.findUserById(userId);
return Boolean(user);
}
defaultMessage() {
return 'Пользователь не найден';
}
}
При асинхронной валидации class-validator автоматически ожидает результат выполнения промиса и агрегирует ошибки валидации после завершения всех проверок.
ValidatorConstraint сам по себе не принимает динамические параметры напрямую. Для передачи параметров используется фабрика декораторов, которая оборачивает constraint и сохраняет конфигурацию в ValidationArguments.
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
function MinValue(min: number, options?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'minValue',
target: object.constructor,
propertyName,
options,
constraints: [min],
validator: {
validate(value: number, args: ValidationArguments) {
const [min] = args.constraints;
return typeof value === 'number' && value >= min;
},
},
});
};
}
Хотя это альтернативный подход, он часто комбинируется с @ValidatorConstraint для более сложных сценариев.
При интеграции с контейнером зависимостей (например, TypeDI) валидаторы могут получать сервисы напрямую через конструктор. Это особенно важно при проверках, связанных с базой данных или внешними сервисами.
import { Service } from 'typedi';
@Service()
@ValidatorConstraint({ name: 'isEmailUnique', async: true })
class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
constructor(private userService: UserService) {}
async validate(email: string) {
const user = await this.userService.findByEmail(email);
return !user;
}
defaultMessage() {
return 'Email уже используется';
}
}
При таком подходе class-validator не создает экземпляр вручную, а делегирует создание DI-контейнеру. Это изменяет поведение жизненного цикла constraint-класса и позволяет внедрять зависимости без глобальных синглтонов.
Метод validate получает объект ValidationArguments, содержащий метаданные текущей проверки:
validate(value: any, args: ValidationArguments) {
const [relatedProperty] = args.constraints;
const relatedValue = (args.object as any)[relatedProperty];
return value !== relatedValue;
}
Этот механизм позволяет создавать валидаторы, зависящие от состояния других полей объекта.
Constraint-классы в class-validator являются переиспользуемыми единицами логики. Один и тот же валидатор может применяться к разным DTO без изменения реализации.
class UserDto {
@Validate(IsEvenConstraint)
age: number;
}
class ProductDto {
@Validate(IsEvenConstraint)
stock: number;
}
При необходимости композиции нескольких проверок используется цепочка декораторов или комбинированные валидаторы, где один constraint вызывает другие внутри validate.
Регистрация через @ValidatorConstraint происходит на этапе загрузки модуля. Это означает, что валидатор становится частью глобального реестра до выполнения валидации.
При этом:
Такой подход делает constraint-классы функционально ближе к stateless-сервисам, несмотря на объектную структуру.
Одной из частых проблем является попытка использовать состояние внутри валидатора:
let counter = 0;
@ValidatorConstraint()
class BadConstraint {
validate(value: number) {
counter++;
return value > 0;
}
}
Такой подход нарушает предсказуемость валидации, особенно при параллельной обработке запросов.
Другой проблемой является отсутствие async: true при использовании await внутри validate, что приводит к некорректной обработке результата.
@ValidatorConstraint не работает изолированно. Он взаимодействует с системой reflect-metadata, где хранится информация о декораторах полей. При вызове validate из validate(dto) библиотека:
Эта архитектура позволяет расширять систему без модификации ядра, добавляя новые constraint-классы как плагины поведения.