Регистрация валидатора через @ValidatorConstraint

В библиотеке class-validator пользовательские валидаторы строятся вокруг интерфейса ValidatorConstraintInterface и механизма регистрации через декоратор @ValidatorConstraint. Этот подход позволяет превращать произвольную бизнес-логику проверки в переиспользуемые классы, которые интегрируются в систему валидации на уровне метаданных.

Основная идея заключается в том, что валидатор оформляется как класс, помеченный специальным декоратором, после чего становится доступным для использования в декораторах полей моделей. Такой подход обеспечивает слабую связанность между схемой данных и логикой проверки, а также упрощает тестирование и масштабирование правил валидации.


Любой кастомный валидатор в class-validator представляет собой класс, реализующий интерфейс ValidatorConstraintInterface. Внутри определяются как минимум два метода:

  • validate — основная логика проверки
  • defaultMessage — сообщение об ошибке по умолчанию
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 в регистрации

Декоратор @ValidatorConstraint выполняет регистрацию класса в глобальном реестре валидаторов библиотеки. При компиляции метаданных TypeScript сохраняется информация о том, что данный класс является constraint-валидатором.

Он принимает параметры конфигурации:

  • name — уникальное имя валидатора
  • async — признак асинхронной валидации
@ValidatorConstraint({ name: 'isEven', async: false })
class IsEvenConstraint implements ValidatorConstraintInterface {
  validate(value: number) {
    return value % 2 === 0;
  }

  defaultMessage() {
    return 'Число должно быть чётным';
  }
}

Указание имени критично при интеграции с системой DI и повторном использовании валидатора в различных контекстах. Без явного имени class-validator использует имя класса, что может привести к конфликтам при минификации или переименовании.


Использование валидатора через @Validate

После регистрации класс применяется в моделях через декоратор @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 для более сложных сценариев.


Использование dependency injection

При интеграции с контейнером зависимостей (например, 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-класса и позволяет внедрять зависимости без глобальных синглтонов.


Контекст ValidationArguments

Метод validate получает объект ValidationArguments, содержащий метаданные текущей проверки:

  • value — проверяемое значение
  • constraints — переданные параметры
  • object — текущий объект DTO
  • property — имя свойства
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 происходит на этапе загрузки модуля. Это означает, что валидатор становится частью глобального реестра до выполнения валидации.

При этом:

  • экземпляр класса может создаваться многократно
  • состояние внутри валидатора не должно храниться между вызовами validate
  • асинхронные операции должны корректно завершаться без побочных эффектов

Такой подход делает constraint-классы функционально ближе к stateless-сервисам, несмотря на объектную структуру.


Типичные ошибки при использовании @ValidatorConstraint

Одной из частых проблем является попытка использовать состояние внутри валидатора:

let counter = 0;

@ValidatorConstraint()
class BadConstraint {
  validate(value: number) {
    counter++;
    return value > 0;
  }
}

Такой подход нарушает предсказуемость валидации, особенно при параллельной обработке запросов.

Другой проблемой является отсутствие async: true при использовании await внутри validate, что приводит к некорректной обработке результата.


Связь с системой метаданных class-validator

@ValidatorConstraint не работает изолированно. Он взаимодействует с системой reflect-metadata, где хранится информация о декораторах полей. При вызове validate из validate(dto) библиотека:

  • читает метаданные классов
  • собирает список валидаторов
  • создает экземпляры constraint-классов
  • выполняет validate для каждого правила
  • агрегирует ошибки валидации

Эта архитектура позволяет расширять систему без модификации ядра, добавляя новые constraint-классы как плагины поведения.