Опирается на правила: R-VLD-CC-1R-VLD-CC-5 и R-VLD-CC-X1R-VLD-CC-X3 из Validation Style Guide → раздел 3. Custom constraints.

Важно знать

  • Custom constraint — пара: @ValidatorConstraint-класс + функция-декоратор через registerDecorator.
  • Пишем custom, только когда формат встречается в 3+ местах или несёт доменный смысл (RussianPhone, VatNumber, BicCode).
  • validate(null) возвращает true. Null-проверка делается отдельным @IsNotEmpty()/@IsDefined(). Иначе ломается composability.
  • Validator stateless — pure-функция от значения. DI (если нужен справочник) — через useContainer(app, { fallbackOnErrors: true }).
  • Расположение: common/validation/ в общем модуле. Не inline в файле DTO.
  • Имена — RussianPhone, VatNumber без префиксов Valid/Check/Is. Функция-декоратор называется так же.
  • Inline @Validate(fn) лямбдой вместо именованной пары — антипаттерн: не переиспользуется, не находится grep-ом.

Custom constraint — способ выразить доменное правило на уровне имени декоратора. Когда правило «телефон в формате +7XXXXXXXXXX» встречается на четвёртом поле, нужен @RussianPhone() — не копипаст regex. Раскрытие раздела 3 гайда.

Когда вводим

Триггер — формат встречается в 3+ местах или имеет доменное имя:

  • @Matches(/^\+7\d{10}$/) появляется в CreateCustomerRequest, UpdateProfileRequest, ContactInfoRequest.
  • Формат имеет устоявшееся название в проекте: ИНН, BIC, SNILS, VIN.
  • Валидация сложнее regex: контрольная сумма ИНН, алгоритм Луна для карты.

Когда не нужен:

  • Формат уникален для одного поля (@Matches(/^CTR-\d{4}-\d{8}$/) остаётся inline).
  • Стандартный декоратор покрывает (@IsEmail() вместо @ValidEmail()).

Структура: @ValidatorConstraint + registerDecorator

R-VLD-CC-1: переиспользуемая пара — два элемента.

// common/validation/russian-phone.ts
import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationOptions,
  registerDecorator,
} from 'class-validator';

const RU_PHONE = /^\+7\d{10}$/;

@ValidatorConstraint({ name: 'russianPhone', async: false })
export class RussianPhoneConstraint implements ValidatorConstraintInterface {
  validate(value: unknown): boolean {
    if (value === null || value === undefined) return true;   // R-VLD-CC-4
    return typeof value === 'string' && RU_PHONE.test(value);
  }

  defaultMessage(): string {
    return 'Номер должен быть в формате +7XXXXXXXXXX';       // R-VLD-MSG-1
  }
}

export function RussianPhone(options?: ValidationOptions) {
  return (object: object, propertyName: string) =>
    registerDecorator({
      target: object.constructor,
      propertyName,
      options,
      validator: RussianPhoneConstraint,
    });
}

R-VLD-CC-2: файл — в common/validation/, не рядом с DTO. R-VLD-CC-3: имя RussianPhone без Valid/Check/Is.

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

export class CreateCustomerRequest {
  @IsNotEmpty({ message: 'Телефон обязателен' })
  @RussianPhone()
  phone: string;

  @IsOptional()
  @RussianPhone()
  alternatePhone?: string;          // если пришло — должно быть в формате; null допустим
}

validate(null) возвращает true

R-VLD-CC-4, R-VLD-CC-X1: главное правило composability.

// ХОРОШО — null возвращает true
validate(value: unknown): boolean {
  if (value === null || value === undefined) return true;
  return typeof value === 'string' && RU_PHONE.test(value);
}

// ПЛОХО — false на null
validate(value: unknown): boolean {
  return typeof value === 'string' && RU_PHONE.test(value);
  // null/undefined → false → ошибка «неверный формат» вместо «поле обязательно»
}

Зачем: @IsNotEmpty() @RussianPhone()@IsNotEmpty() ловит null/empty, @RussianPhone() — формат. Два правила, одна ответственность каждое. Если @RussianPhone() фейлит на null, optional-поле (@IsOptional() @RussianPhone()) невозможно использовать.

Stateless validator

R-VLD-CC-5: pure-функция от значения, без мутируемых полей.

// ХОРОШО — pure
@ValidatorConstraint({ name: 'vatNumber', async: false })
export class VatNumberConstraint implements ValidatorConstraintInterface {
  private static readonly INN_WEIGHTS_10 = [2, 4, 10, 3, 5, 9, 4, 6, 8];

  validate(value: unknown): boolean {
    if (value === null || value === undefined) return true;
    if (typeof value !== 'string' || !/^\d{10}$/.test(value)) return false;
    const digits = value.split('').map(Number);
    const sum = VatNumberConstraint.INN_WEIGHTS_10.reduce(
      (acc, w, i) => acc + w * digits[i], 0,
    );
    return (sum % 11 % 10) === digits[9];
  }

  defaultMessage(): string { return 'Неверный ИНН (10 цифр, контрольная сумма)'; }
}

Если валидатору нужен справочник (например, список допустимых кодов BIC из БД), загружаем один раз при старте:

@ValidatorConstraint({ name: 'bicCode', async: false })
@Injectable()                          // Injectable для DI
export class BicCodeConstraint implements ValidatorConstraintInterface {
  private readonly validCodes: Set<string>;

  constructor(private readonly registry: BicCodeRegistry) {
    this.validCodes = new Set(registry.loadAll());   // загружается один раз
  }

  validate(value: unknown): boolean {
    if (value === null || value === undefined) return true;
    return typeof value === 'string' && this.validCodes.has(value);
  }

  defaultMessage(): string { return 'Неверный код BIC'; }
}

Для DI в констрейнт — useContainer в main.ts:

import { useContainer } from 'class-validator';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  useContainer(app.select(AppModule), { fallbackOnErrors: true });
  // ...
}

Без useContainer NestJS DI недоступен внутри ValidatorConstraintInterface.

Расположение и нэйминг

R-VLD-CC-2, R-VLD-CC-3: всё в common/validation/, имя по доменному термину.

common/validation/
  russian-phone.ts        // @RussianPhone
  vat-number.ts           // @VatNumber
  bic-code.ts             // @BicCode
  url-safe-base64.ts      // @UrlSafeBase64

Для доменно-специфичных (применяются только в одном BC):

core/order/validation/
  order-number.ts         // @OrderNumber — только для заказов
// ХОРОШО — по доменному термину
@RussianPhone()
@VatNumber()
@BicCode()

// ПЛОХО — с префиксом
@ValidPhone()
@CheckVat()
@IsBicCode()

@RussianPhone читается: «поле phone — это российский телефон». Существительное, не глагол.

Inline в DTO и @Validate лямбдой — антипаттерны

R-VLD-CC-X2, R-VLD-CC-X3:

// ПЛОХО — класс-констрейнт в файле DTO
export class CreateCustomerRequest {
  @Validate(class implements ValidatorConstraintInterface {
    validate(v: unknown) { return typeof v === 'string' && /^\+7\d{10}$/.test(v as string); }
    defaultMessage() { return 'Неверный формат'; }
  })
  phone: string;
}

Что не так:

  • Не переиспользуется. В UpdateProfileRequest придётся скопировать.
  • Не находится grep-ом. grep -r 'RussianPhone' — пусто. Правило спрятано.
  • Без имени. Аноним-класс не объясняет, что именно проверяется.

Правильно — именованный RussianPhoneConstraint в common/validation/russian-phone.ts, @RussianPhone() применяется где угодно.

Что запрещено

АнтипаттернПравилоЧто взамен
validate(null) возвращает falseR-VLD-CC-X1if (value == null) return true, null проверяется @IsNotEmpty()
Констрейнт inline в файле DTOR-VLD-CC-X2Отдельный файл в common/validation/
Анонимная лямбда в @Validate(...)R-VLD-CC-X3Именованная пара @ValidatorConstraint + registerDecorator
Мутируемое поле в констрейнте (runtime-state)R-VLD-CC-5Pure-функция; справочник — readonly в конструкторе
Имя с префиксом Valid/Check/IsR-VLD-CC-3RussianPhone, VatNumber — существительное
Custom constraint для формата с одним применениемInline @Matches(/.../)
Custom constraint вместо стандартного @IsEmail()R-VLD-STD-X2@IsEmail()

Куда дальше

  • Validation → раздел 3. Custom constraints — нормативные формулировки R-VLD-CC-*.
  • Стандартные constraints — что есть из коробки, до custom.
  • Cross-field validation — class-level constraint для правил между полями.
  • Messages и i18n — как писать defaultMessage() и где локализация.