Опирается на правила:
R-VLD-CC-1…R-VLD-CC-5иR-VLD-CC-X1…R-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) возвращает false | R-VLD-CC-X1 | if (value == null) return true, null проверяется @IsNotEmpty() |
| Констрейнт inline в файле DTO | R-VLD-CC-X2 | Отдельный файл в common/validation/ |
Анонимная лямбда в @Validate(...) | R-VLD-CC-X3 | Именованная пара @ValidatorConstraint + registerDecorator |
| Мутируемое поле в констрейнте (runtime-state) | R-VLD-CC-5 | Pure-функция; справочник — readonly в конструкторе |
Имя с префиксом Valid/Check/Is | R-VLD-CC-3 | RussianPhone, 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()и где локализация.