open atlas
↑ Back to track
NestJS, zero to senior NEST · 02 · 05

Custom and async validators (and their traps)

Custom constraints: a @ValidatorConstraint class or a registerDecorator factory. Async DB validators hide traps — forgetting useContainer leaves injected repos undefined, and the read-then-write race means only a DB unique index, not the validator, guarantees uniqueness.

NEST Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The signup form looked airtight. Someone had written a clean @IsEmailUnique() decorator: it queried the users table inside the validator, returned a friendly 400 "email already taken", and the demo was flawless. It shipped. Two weeks later support pulled up two accounts with the exact same email, and a third request had crashed with a Postgres 500. Nothing in the code looked wrong — the validator was right there. But the validator had been a lie about what it guaranteed: it read the table, found the email absent, and waved the request through — and the request next to it, half a millisecond later, did exactly the same read and got the same “absent”. Both passed. Both inserted. This lesson is about custom and async validators: how to build them, why the injected repository is sometimes undefined, and why a validator can never be the thing that keeps two rows apart.

Two ways to write a custom constraint

class-validator gives you two shapes for a custom rule. The first is a constraint class: a class decorated with @ValidatorConstraint that implements ValidatorConstraintInterface. You write validate() (return boolean or Promise<boolean>) and defaultMessage(), then attach it to a property with @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;
}

The second shape is a decorator factory built on registerDecorator. This is what you reach for when you want a reusable @IsEmailUnique() you can drop onto any property — it wraps the same constraint interface in a function that registers it against the target and property:

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

// Reusable decorator: @IsEmailUnique() — wraps a constraint via 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
    });
  };
}

Either way the rule lives in a constraint class. The decorator factory is just ergonomic packaging so the call site reads @IsEmailUnique() instead of @Validate(IsEmailUniqueConstraint).

Async validators and the DI trap

A uniqueness check has to talk to the database, so its validate() returns a Promise<boolean> and the constraint is marked async: true. To query, it needs the users repository — so you inject it, like any other Nest service:

@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';
  }
}

This compiles, and then crashes at runtime: Cannot read properties of undefined (reading 'exists'). The reason is the trap. class-validator instantiates constraint classes itself, outside Nest’s DI container — it just does new IsEmailUniqueConstraint(), with no idea your constructor wanted a repository. So this.users is undefined. Depending on how you wrote the guard, that’s either a crash or — worse — a validator that silently always passes because the await on undefined throws and gets swallowed.

The one-line fix lives in main.ts. useContainer (exported by class-validator) tells it to resolve constraints through Nest’s container instead of new-ing them itself:

import { useContainer } from 'class-validator';
import { AppModule } from './app.module';

const app = await NestFactory.create(AppModule);
// Route constraint instantiation through Nest's DI so @Injectable() deps resolve
useContainer(app.select(AppModule), { fallbackOnErrors: true });

Now IsEmailUniqueConstraint is built by Nest, gets its UsersRepository, and this.users is real. The fallbackOnErrors: true matters: class-validator’s built-in constraints (@IsEmail, @IsString, …) aren’t Nest providers, so Nest can’t resolve them — fallbackOnErrors lets class-validator fall back to its own new for those instead of throwing. Without it, every built-in validator becomes a “provider not found” error.

Why this works

Why isn’t a passing async uniqueness validator enough to guarantee uniqueness? Because it is a read-then-write check with a gap in the middle. The validator runs a SELECT … WHERE email = ? and sees no row. But “no row right now” is not “no row when I insert.” Two concurrent signups with the same email both run their SELECT before either has INSERTed — so both see “absent,” both pass validation, and both proceed to insert. Neither validator is wrong about what it saw; they just both saw a world that stopped being true a moment later. Only the database can close that gap, because only the database can serialize the two inserts against a shared UNIQUE index — it lets the first commit and rejects the second. The validator reduces friction (a clean 400 for the common case); the index enforces truth.

Three traps, and why the index is the real fix

Putting a DB read in the validation layer buys you three problems, in increasing order of severity. When you see all three together you understand why the validator and the index are not alternatives — each closes a gap the other cannot.

(1) DI — the one above: forget useContainer and the injected repository is undefined. A deploy-day crash, easy to find once you know it exists.

(2) Perf and enumeration — validation now runs a query on every request to that endpoint, before the handler, including malformed and malicious ones. That’s a DB round trip per request, and worse, the distinct "email already taken" message turns the endpoint into a user-enumeration oracle: an attacker scripts it to learn which emails have accounts. Mitigate with rate limiting and a message that doesn’t confirm existence.

(3) The TOCTOU race — the real lesson. This is the production incident. The uniqueness check is read-then-write, so under concurrency two signups both pass the validator and both insert. The outcome is either two rows with the same email (no constraint) or a raw Postgres unique-violation surfacing as a 500 (constraint present, unhandled). The fix is to stop pretending validation is a 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;
}

Validation is not a constraint. Keep the async @IsEmailUnique() — it gives the common case a friendly 400 without a stack trace. But the UNIQUE index is the source of truth: it is the only thing that serializes the two concurrent inserts, and catching 23505 turns the loser of that race into a clean 409 instead of a leaked 500. The validator is the UX; the index is the guarantee.

Pick the best fit

You must guarantee that no two users ever share an email, even under concurrent signups. Which approach actually delivers the guarantee?

Quiz

Your async IsEmailUniqueConstraint injects a UsersRepository, but at runtime this.users is undefined and validation crashes. What is missing?

Quiz

Two concurrent signups with the same email both pass the async @IsEmailUnique validator. What actually guarantees that only one of them succeeds?

Recall before you leave
  1. 01
    Describe the two ways to write a custom class-validator constraint, and explain the DI trap that makes an injected repository undefined in an async validator plus its fix.
  2. 02
    Why can an async uniqueness validator never guarantee uniqueness, and what is the correct uniqueness strategy in Nest + Postgres?
Recap

Custom class-validator constraints come two ways: a class decorated with @ValidatorConstraint implementing ValidatorConstraintInterface (validate + defaultMessage, attached via @Validate), or a reusable decorator factory built on registerDecorator that packages the same constraint as @IsEmailUnique(). An async DB check marks the constraint async: true and returns Promise<boolean>, and must inject its repository — but class-validator instantiates constraints itself with plain new, outside Nest’s DI, so the injected repo is undefined and validation crashes. The one-line fix is useContainer(app.select(AppModule), { fallbackOnErrors: true }) in main.ts, which routes instantiation through Nest’s container so @Injectable() deps resolve; fallbackOnErrors lets the built-in validators fall back to class-validator’s own new instead of erroring. Async DB validation then carries three traps: forgetting useContainer (undefined repo), a DB round trip on every request plus a user-enumeration oracle from a distinct “email taken” message (mitigate with rate limiting and a vague message), and the real one — TOCTOU. Because the check is read-then-write, two concurrent signups both SELECT ‘absent’ before either INSERTs, both pass, and both insert, yielding duplicates or an unhandled 500. Validation is not a constraint: a DB UNIQUE index is the source of truth that serializes the inserts, and you catch the unique-violation (Postgres 23505) and map it to a clean 409. Keep the validator for the friendly message, but the index is the guarantee. Now when you see a uniqueness check in a validator, your first question should be: is there a UNIQUE index, and does the service catch 23505 — because the validator alone is a promise, not a proof.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.