Создание простого кастомного декоратора

В библиотеке class-validator основной механизм расширения валидации строится вокруг пользовательских декораторов. Встроенных ограничений достаточно для базовых сценариев (строки, числа, диапазоны, форматы), однако реальные доменные модели часто требуют специфических правил, которые невозможно выразить стандартными средствами. Для таких случаев применяется создание кастомных декораторов через registerDecorator.

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


Базовая структура кастомного декоратора

Основой любого пользовательского декоратора является функция, внутри которой вызывается registerDecorator.

import {
  registerDecorator,
  ValidationOptions,
  ValidationArguments,
} from 'class-validator';

export function IsPositiveEven(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isPositiveEven',
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          return typeof value === 'number' && value > 0 && value % 2 === 0;
        },
      },
    });
  };
}

В этой конструкции ключевыми элементами являются:

  • name — уникальное имя правила;
  • target — класс, к которому привязан декоратор;
  • propertyName — имя проверяемого свойства;
  • options — параметры валидации (сообщения, группы, условия);
  • validator.validate — функция проверки значения.

Простейший пример: проверка чётного числа

Минимальный кастомный декоратор может не содержать параметров и проверять одно условие.

export function IsEven(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isEven',
      target: object.constructor,
      propertyName,
      options: validationOptions,
      validator: {
        validate(value: any) {
          return typeof value === 'number' && value % 2 === 0;
        },
      },
    });
  };
}

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

import { IsEven } from './validators';

class Product {
  @IsEven({ message: 'Значение должно быть чётным числом' })
  quantity: number;
}

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

ValidationArguments предоставляет доступ к контексту валидации: объекту, имени свойства и другим метаданным. Это позволяет создавать более гибкие проверки.

export function IsGreaterThan(property: string, validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isGreaterThan',
      target: object.constructor,
      propertyName,
      constraints: [property],
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          const relatedPropertyName = args.constraints[0];
          const relatedValue = (args.object as any)[relatedPropertyName];

          return typeof value === 'number' &&
                 typeof relatedValue === 'number' &&
                 value > relatedValue;
        },
      },
    });
  };
}

Такой подход позволяет сравнивать значения разных полей внутри одного объекта.

class Range {
  min: number;

  @IsGreaterThan('min', {
    message: 'max должно быть больше min',
  })
  max: number;
}

Передача параметров в декоратор

Кастомные декораторы могут принимать дополнительные параметры через constraints. Это позволяет делать их универсальными.

export function MinLengthCustom(min: number, validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'minLengthCustom',
      target: object.constructor,
      propertyName,
      constraints: [min],
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          const [minLength] = args.constraints;
          return typeof value === 'string' && value.length >= minLength;
        },
      },
    });
  };
}

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

class User {
  @MinLengthCustom(8, {
    message: 'Пароль слишком короткий',
  })
  password: string;
}

Асинхронная валидация

class-validator поддерживает асинхронные проверки, например обращение к базе данных или внешнему API. Для этого validate должен возвращать Promise<boolean>.

export function IsEmailUnique(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isEmailUnique',
      target: object.constructor,
      propertyName,
      options: validationOptions,
      validator: {
        async validate(value: any) {
          const existingUser = await fakeDatabase.findUserByEmail(value);
          return !existingUser;
        },
      },
    });
  };
}

Асинхронные декораторы особенно важны в доменных системах с уникальными ограничениями.


Работа с объектом и контекстом

Внутри validate доступен полный объект, что позволяет реализовывать сложные межполевые зависимости.

export function IsAfter(property: string, validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isAfter',
      target: object.constructor,
      propertyName,
      constraints: [property],
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          const relatedProperty = args.constraints[0];
          const relatedValue = (args.object as any)[relatedProperty];

          if (!value || !relatedValue) return false;

          return new Date(value) > new Date(relatedValue);
        },
      },
    });
  };
}

Пример применения:

class Event {
  startDate: string;

  @IsAfter('startDate', {
    message: 'Дата окончания должна быть позже даты начала',
  })
  endDate: string;
}

Повторное использование и композиция

Кастомные декораторы можно комбинировать с встроенными ограничениями class-validator, создавая многоуровневую валидацию.

import { IsString, IsNotEmpty } from 'class-validator';

class Account {
  @IsString()
  @IsNotEmpty()
  @MinLengthCustom(5)
  username: string;
}

Такой подход позволяет разделять ответственность: базовые проверки остаются декларативными, а доменная логика выносится в отдельные декораторы.


Расширенная конфигурация через ValidationOptions

ValidationOptions управляет поведением ошибки и группировкой правил.

registerDecorator({
  name: 'isEven',
  target: object.constructor,
  propertyName,
  options: {
    message: 'Число должно быть чётным',
    groups: ['numeric'],
    ...validationOptions,
  },
  validator: {
    validate(value: any) {
      return typeof value === 'number' && value % 2 === 0;
    },
  },
});

Основные возможности:

  • message — текст ошибки;
  • groups — группировка правил;
  • always — постоянная проверка независимо от условий;
  • context — пользовательские данные для обработчиков ошибок.

Типичные ошибки при создании декораторов

Одной из частых проблем является потеря контекста this. Внутри validate нельзя полагаться на this, поскольку функция вызывается вне класса.

validator: {
  validate(value: any) {
    return this.check(value); // некорректно
  },
}

Корректный подход — использование ValidationArguments.


Ещё одна ошибка — неправильная работа с асинхронностью. Если функция возвращает Promise, но не объявлена как async, поведение становится непредсказуемым.


Работа с несколькими условиями

Кастомный декоратор может включать сложную бизнес-логику, объединяя несколько проверок.

export function IsValidDiscount(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isValidDiscount',
      target: object.constructor,
      propertyName,
      options: validationOptions,
      validator: {
        validate(value: any) {
          return (
            typeof value === 'number' &&
            value >= 0 &&
            value <= 100 &&
            Number.isInteger(value)
          );
        },
      },
    });
  };
}

Сложные сценарии с зависимостями между полями

При проектировании моделей часто требуется проверка согласованности данных.

export function IsNotSameAs(property: string, validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      name: 'isNotSameAs',
      target: object.constructor,
      propertyName,
      constraints: [property],
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          const related = (args.object as any)[args.constraints[0]];
          return value !== related;
        },
      },
    });
  };
}

Организация кастомных декораторов в проекте

При росте проекта декораторы обычно выносятся в отдельный слой:

  • validators/

    • string.validators.ts
    • number.validators.ts
    • date.validators.ts
    • business.validators.ts

Такое разделение снижает связность и упрощает повторное использование в разных модулях.


Итоговая структура кастомного декоратора

Типичный декоратор включает:

  • фабрику функции с параметрами;
  • регистрацию через registerDecorator;
  • объект validator с validate;
  • доступ к ValidationArguments при необходимости;
  • поддержку ValidationOptions.

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