Кастомные и асинхронные валидаторы (и их ловушки)
Кастомные constraint: класс @ValidatorConstraint или фабрика registerDecorator. Асинхронные валидаторы в БД прячут ловушки: забыл useContainer — инжектированный repo undefined; а гонка read-then-write значит, что уникальность гарантирует только UNIQUE-индекс БД, а не валидатор.
Форма регистрации выглядела герметичной. Кто-то написал чистый декоратор @IsEmailUnique(): он запрашивал таблицу пользователей внутри валидатора, возвращал дружелюбный 400 "email already taken", и демо было безупречным. Поехало в прод. Через две недели поддержка вытащила два аккаунта с ровно одинаковым email, а третий запрос упал с Postgres-500. Ничто в коде не выглядело неправильным — валидатор был прямо вот тут. Но валидатор лгал о том, что он гарантирует: он прочитал таблицу, нашёл email отсутствующим и пропустил запрос дальше — а соседний запрос, на полмиллисекунды позже, сделал ровно то же чтение и получил то же «отсутствует». Оба прошли. Оба вставили. Этот урок — про кастомные и асинхронные валидаторы: как их строить, почему инжектированный repository иногда undefined и почему валидатор никогда не может быть тем, что держит две строки порознь.
Два способа написать кастомный constraint
class-validator даёт две формы для кастомного правила. Первая — класс constraint: класс, помеченный @ValidatorConstraint, реализующий ValidatorConstraintInterface. Ты пишешь validate() (возвращает boolean или Promise<boolean>) и defaultMessage(), затем прикрепляешь его к свойству через @Validate(MyConstraint).
import {
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
Validate,
} from 'class-validator';
// A synchronous rule — no I/O, just logic over the value
@ValidatorConstraint({ name: 'isStrongPassword', async: false })
export class IsStrongPasswordConstraint implements ValidatorConstraintInterface {
validate(value: string): boolean {
return typeof value === 'string'
&& value.length >= 12
&& /[a-z]/.test(value)
&& /[A-Z]/.test(value)
&& /[0-9]/.test(value);
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} must be ≥12 chars with upper, lower, and a digit`;
}
}
export class SignupDto {
@Validate(IsStrongPasswordConstraint)
password: string;
}Вторая форма — фабрика декоратора на базе registerDecorator. За ней тянешься, когда хочешь переиспользуемый @IsEmailUnique(), который можно ронять на любое свойство — она оборачивает тот же интерфейс constraint в функцию, регистрирующую его против target и property:
import { registerDecorator, ValidationOptions } from 'class-validator';
// Переиспользуемый декоратор: @IsEmailUnique() — оборачивает constraint через registerDecorator
export function IsEmailUnique(options?: ValidationOptions) {
return function (target: object, propertyName: string) {
registerDecorator({
name: 'isEmailUnique',
target: target.constructor,
propertyName,
options,
validator: IsEmailUniqueConstraint, // the @Injectable() class below
});
};
}В любом случае правило живёт в классе constraint. Фабрика декоратора — лишь эргономичная упаковка, чтобы место вызова читалось как @IsEmailUnique(), а не @Validate(IsEmailUniqueConstraint).
Асинхронные валидаторы и ловушка DI
Проверка уникальности обязана говорить с базой, так что её validate() возвращает Promise<boolean>, а constraint помечен async: true. Чтобы запросить, ему нужен repository пользователей — поэтому ты инжектишь его, как любой другой сервис Nest:
@ValidatorConstraint({ name: 'isEmailUnique', async: true })
@Injectable()
export class IsEmailUniqueConstraint implements ValidatorConstraintInterface {
constructor(private readonly users: UsersRepository) {} // injected dep
async validate(email: string): Promise<boolean> {
// true = valid = email is NOT taken
return !(await this.users.exists({ email }));
}
defaultMessage(): string {
return 'email already taken';
}
}Это компилируется, а затем падает в рантайме: Cannot read properties of undefined (reading 'exists'). Причина — та самая ловушка. class-validator инстанцирует классы constraint сам, вне DI-контейнера Nest — он просто делает new IsEmailUniqueConstraint(), не имея понятия, что твой конструктор хотел repository. Так что this.users — undefined. В зависимости от того, как ты написал защиту, это либо краш, либо — хуже — валидатор, который молча всегда проходит, потому что await на undefined выбрасывает, а исключение проглатывается.
Фикс в одну строку живёт в main.ts. useContainer (экспортируется из class-validator) говорит ему резолвить constraint через контейнер Nest вместо того, чтобы new-ить их самому:
import { useContainer } from 'class-validator';
import { AppModule } from './app.module';
const app = await NestFactory.create(AppModule);
// Инстанцирование constraint через DI Nest, чтобы @Injectable() зависимости резолвились
useContainer(app.select(AppModule), { fallbackOnErrors: true });Теперь IsEmailUniqueConstraint строится Nest, получает свой UsersRepository, и this.users реален. fallbackOnErrors: true важен: встроенные constraint class-validator (@IsEmail, @IsString, …) — не провайдеры Nest, так что Nest не может их зарезолвить — fallbackOnErrors позволяет class-validator откатиться на свой собственный new для них вместо того, чтобы выбросить. Без него каждый встроенный валидатор станет ошибкой «provider not found».
▸Почему это работает
Почему проходящий асинхронный валидатор уникальности недостаточен, чтобы гарантировать уникальность? Потому что это проверка read-then-write с зазором посередине. Валидатор выполняет SELECT … WHERE email = ? и не видит строки. Но «нет строки прямо сейчас» — не «нет строки, когда я вставлю». Две конкурентные регистрации с одинаковым email обе выполняют свой SELECT до того, как любая сделала INSERT — так что обе видят «отсутствует», обе проходят валидацию и обе идут вставлять. Ни один валидатор не неправ насчёт того, что увидел; они просто оба увидели мир, который перестал быть истинным мгновением позже. Только база может закрыть этот зазор, потому что только база может сериализовать две вставки против общего UNIQUE-индекса — она пропускает первый commit и отвергает второй. Валидатор снижает трение (чистый 400 для общего случая); индекс обеспечивает истину.
Три ловушки, и почему индекс — настоящий фикс
Размещение чтения из БД в слое валидации покупает тебе три проблемы, по возрастанию серьёзности. Когда видишь их вместе, понимаешь, почему валидатор и индекс — это не альтернативы: каждый закрывает дыру, которую другой закрыть не может.
(1) DI — та, что выше: забыл useContainer — и инжектированный repository undefined. Краш в день деплоя, легко найти, как только знаешь, что он существует.
(2) Производительность и enumeration — валидация теперь выполняет запрос на каждый запрос к этому эндпоинту, до хендлера, включая некорректные и злонамеренные. Это DB round trip на запрос, и хуже того, отличимое сообщение "email already taken" превращает эндпоинт в оракул user-enumeration: атакующий скриптует его, чтобы узнать, у каких email есть аккаунты. Митигируй rate limiting и сообщением, которое не подтверждает существование.
(3) Гонка TOCTOU — настоящий урок. Это и есть прод-инцидент. Проверка уникальности — это read-then-write, так что при конкуренции две регистрации обе проходят валидатор и обе вставляют. Исход — либо две строки с одинаковым email (нет constraint), либо сырое нарушение уникальности Postgres, всплывающее как 500 (constraint есть, не обработан). Фикс — перестать притворяться, что валидация — это constraint:
// 1) The DATABASE is the source of truth — a UNIQUE index serializes inserts
// e.g. CREATE UNIQUE INDEX users_email_key ON users (lower(email));
// 2) Catch the unique-violation and map it to a clean 409 — Postgres code 23505
try {
return await this.users.insert(dto);
} catch (e) {
if (e.code === '23505') { // unique_violation
throw new ConflictException('email already taken'); // 409, not 500
}
throw e;
}Валидация — это не constraint. Оставь асинхронный @IsEmailUnique() — он даёт общему случаю дружелюбный 400 без stack trace. Но UNIQUE-индекс — источник истины: он единственное, что сериализует две конкурентные вставки, и отлов 23505 превращает проигравшего в этой гонке в чистый 409 вместо утёкшего 500. Валидатор — это UX; индекс — это гарантия.
Ты должен гарантировать, что никакие два пользователя никогда не делят email, даже при конкурентных регистрациях. Какой подход реально даёт гарантию?
Твой асинхронный IsEmailUniqueConstraint инжектит UsersRepository, но в рантайме this.users — undefined, и валидация падает. Чего не хватает?
Две конкурентные регистрации с одинаковым email обе проходят асинхронный валидатор @IsEmailUnique. Что реально гарантирует, что только одна из них преуспеет?
- 01Опиши два способа написать кастомный constraint class-validator и объясни ловушку DI, из-за которой инжектированный repository undefined в асинхронном валидаторе, плюс её фикс.
- 02Почему асинхронный валидатор уникальности никогда не может гарантировать уникальность и какова правильная стратегия уникальности в Nest + Postgres?
Кастомные constraint class-validator бывают двух форм: класс, помеченный @ValidatorConstraint, реализующий ValidatorConstraintInterface (validate + defaultMessage, прикрепляется через @Validate), либо переиспользуемая фабрика декоратора на базе registerDecorator, упаковывающая тот же constraint как @IsEmailUnique(). Асинхронная проверка в БД помечает constraint async: true и возвращает Promise<boolean>, и обязана инжектить свой repository — но class-validator инстанцирует constraint сам обычным new, вне DI Nest, так что инжектированный repo undefined, и валидация падает. Фикс в одну строку — useContainer(app.select(AppModule), { fallbackOnErrors: true }) в main.ts, которая направляет инстанцирование через контейнер Nest, чтобы @Injectable()-зависимости зарезолвились; fallbackOnErrors позволяет встроенным валидаторам откатиться на собственный new class-validator вместо ошибки. Асинхронная валидация в БД затем несёт три ловушки: забытый useContainer (undefined repo), DB round trip на каждый запрос плюс оракул user-enumeration из отличимого сообщения «email taken» (митигируй rate limiting и расплывчатым сообщением) и настоящую — TOCTOU (Time-of-Check/Time-of-Use — гонка между чтением и записью). Поскольку проверка — это read-then-write, две конкурентные регистрации обе делают SELECT «отсутствует» до любого INSERT, обе проходят и обе вставляют, давая дубликаты или необработанный 500. Валидация — это не constraint: UNIQUE-индекс БД — источник истины, который сериализует вставки, и ты ловишь нарушение уникальности (Postgres 23505) и мапишь в чистый 409. Оставь валидатор ради дружелюбного сообщения, но индекс — это гарантия. Теперь, когда видишь проверку уникальности в валидаторе, твой первый вопрос — есть ли UNIQUE-индекс и ловит ли сервис 23505, — потому что валидатор в одиночку — это обещание, не доказательство.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.