Переиспользование валидационной логики

Валидация данных в прикладных JavaScript/TypeScript-приложениях быстро становится централизованной задачей, поскольку одинаковые правила проверки применяются в разных слоях системы: DTO, сервисах, API-моделях, формах. Дублирование декораторов и условий приводит к рассинхронизации поведения и усложнению поддержки.

Библиотека class-validator предоставляет набор механизмов, позволяющих выносить и повторно использовать валидационную логику на уровне классов, декораторов и пользовательских правил.


Базовая модель повторного использования через декораторы

Самый прямой способ переиспользования — выделение набора декораторов в отдельные композиции.

import { IsString, Length, Matches } fr om 'class-validator';

export function IsUsername() {
  return function (target: Object, propertyKey: string) {
    IsString()(target, propertyKey);
    Length(3, 20)(target, propertyKey);
    Matches(/^[a-zA-Z0-9_]+$/)(target, propertyKey);
  };
}

Такой подход формирует единый контракт поля, который применяется во всех DTO:

class CreateUserDto {
  @IsUsername()
  username: string;
}

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


Фабрики декораторов для параметризации правил

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

import { Length } from 'class-validator';

export function IsSlug(min = 3, max = 50) {
  return function (target: Object, propertyKey: string) {
    Length(min, max)(target, propertyKey);
  };
}

Такая структура позволяет стандартизировать правила, сохраняя гибкость:

class PostDto {
  @IsSlug(5, 100)
  slug: string;
}

Композиция декораторов как базовый механизм масштабирования

Повторное использование усиливается через композицию функций-декораторов.

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

export function IsTitle() {
  return function (target: Object, propertyKey: string) {
    IsString()(target, propertyKey);
    IsNotEmpty()(target, propertyKey);
    MaxLength(120)(target, propertyKey);
  };
}

Композиция позволяет формировать библиотеку доменных правил, а не набор разрозненных проверок.


Наследование DTO как способ наследования валидации

Классовая модель TypeScript позволяет переносить валидацию через наследование.

class BaseUserDto {
  @IsString()
  name: string;
}

class ExtendedUserDto extends BaseUserDto {
  @IsString()
  role: string;
}

При такой структуре базовые правила автоматически применяются ко всем производным моделям. Это особенно эффективно при наличии нескольких версий API.


Переиспользование через composition types и пересечение классов

При необходимости объединения наборов правил используется композиция через пересечение типов.

import { IntersectionType } from '@nestjs/mapped-types';

class NamePart {
  @IsString()
  firstName: string;
}

class ContactPart {
  @IsString()
  email: string;
}

class UserDto extends IntersectionType(NamePart, ContactPart) {}

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


Пользовательские валидаторы как ядро повторного использования

Наиболее мощный механизм переиспользования — создание кастомных валидаторов через ValidatorConstraint.

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments,
} from 'class-validator';

@ValidatorConstraint({ name: 'isEven', async: false })
export class IsEvenConstraint implements ValidatorConstraintInterface {
  validate(value: number) {
    return typeof value === 'number' && value % 2 === 0;
  }

  defaultMessage(args: ValidationArguments) {
    return `Значение ${args.value} не является чётным числом`;
  }
}

Регистрация через декоратор:

import { Validate } from 'class-validator';

export function IsEven() {
  return Validate(IsEvenConstraint);
}

Дальнейшее использование становится декларативным:

class NumberDto {
  @IsEven()
  value: number;
}

Параметризуемые кастомные валидаторы

Повторное использование усиливается, если валидатор принимает параметры через контекст.

@ValidatorConstraint({ name: 'minWords', async: false })
export class MinWordsConstraint implements ValidatorConstraintInterface {
  validate(value: string, args: ValidationArguments) {
    const [min] = args.constraints;
    return typeof value === 'string' && value.split(' ').length >= min;
  }
}

Фабрика:

export function MinWords(min: number) {
  return Validate(MinWordsConstraint, [min]);
}

Разделение доменной и инфраструктурной валидации

Повторное использование становится эффективнее при разделении уровней:

  • доменные правила (например, формат email, длина имени)
  • инфраструктурные правила (например, уникальность в базе данных)
@ValidatorConstraint({ async: true })
export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
  async validate(email: string) {
    return await fakeDbCheck(email);
  }
}

Инкапсуляция таких правил позволяет использовать их в разных DTO без изменения бизнес-логики.


Библиотеки правил как слой абстракции над class-validator

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

// validation-rules package
export const Rules = {
  username: IsUsername,
  slug: IsSlug,
  title: IsTitle,
};

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

class ArticleDto {
  @Rules.title()
  title: string;

  @Rules.slug(5, 80)
  slug: string;
}

Это формирует единый источник истины для всех сервисов.


Переиспользование через метаданные и Reflect API

Некоторые сценарии требуют динамического переиспользования правил. В таких случаях применяется метаданные:

import 'reflect-metadata';

const VALIDATION_KEY = 'validation_rules';

export function AddRule(rule: any) {
  return function (target: any, propertyKey: string) {
    const existing = Reflect.getMetadata(VALIDATION_KEY, target, propertyKey) || [];
    Reflect.defineMetadata(VALIDATION_KEY, [...existing, rule], target, propertyKey);
  };
}

Это позволяет централизованно применять правила на основе внешней конфигурации.


Переиспользование через общие базовые DTO

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

class PaginationDto {
  @IsNumber()
  page: number;

  @IsNumber()
  lim it: number;
}

Далее они включаются в другие DTO:

class ListUsersDto extends PaginationDto {
  @IsOptional()
  @IsString()
  search?: string;
}

Условная переиспользуемая логика через группы валидации

Группы позволяют повторно использовать одно и то же поле в разных сценариях:

class UserDto {
  @IsString({ groups: ['create'] })
  password: string;

  @IsString({ groups: ['update'] })
  id: string;
}

Использование групп снижает необходимость создавать отдельные классы под каждый сценарий.


Миксины как механизм сборки валидационных блоков

Миксины позволяют динамически создавать классы с повторно используемой логикой:

type Constructor<T = {}> = new (...args: any[]) => T;

function Timestamped<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    @IsDate()
    createdAt: Date;

    @IsDate()
    updatedAt: Date;
  };
}

Применение:

class UserBase {
  @IsString()
  name: string;
}

class UserEntity extends Timestamped(UserBase) {}

Централизация правил через доменные спецификации

При сложных доменных моделях логика валидации переносится в отдельные спецификации:

export const UserValidationSpec = {
  username: [IsUsername()],
  email: [IsEmail()],
};

Далее применяется программно:

class UserDto {
  @ApplyRules(UserValidationSpec.username)
  username: string;
}

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