Backend with NestJS
Backend with NestJS

DTOs & validation: the contract at the edge

Full · ~35 min
After this unit you can
  • Explain why a DTO class validates nothing until a ValidationPipe runs, and register it globally
  • Explain why whitelist blocks mass assignment, and fix a pipe to strip, coerce and report every error
  • Coerce path and query strings to numbers with ParseIntPipe or @Type(() => Number) in a query DTO
  • Write DTO rules for optional, nested and PATCH fields: @IsOptional, @ValidateNested + @Type, PartialType
  • Choose the service (409) for uniqueness checks and a field-by-field mapper to keep secrets out of responses

Read

Simple
1 / 2

The DTO is the boundary, not a type

Flag

A request body arrives as unknown — attacker-controlled JSON. A DTO is the one place you turn it into something the rest of the app is allowed to trust.

export class CreateOrderDto {
  @IsUUID()
  customerId: string;

  @IsInt()
  @Min(1)
  @Max(100)
  quantity: number;

  @IsOptional()
  @IsString()
  @MaxLength(280)
  note?: string;
}

@Post()
create(@Body() dto: CreateOrderDto) {
  // Past this line, `dto` is validated data — not input.
  return this.orders.create(dto);
}

The critical thing to understand: @Body() dto: CreateOrderDto alone validates nothing. TypeScript checks nothing at runtime — the annotation survives only as metadata naming the class. What enforces the contract is a runtime pipe reading that metadata and the decorators:

// main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

Without that line the class is documentation. With it, a bad body never reaches your handler — the pipe throws a 400 Bad Request listing every failure.

So a DTO does three jobs at once:

  • Shape — which fields exist, which are optional.
  • Constraints — the rules a value must satisfy (@IsInt, @Min, @IsEmail).
  • Trust boundary — one line in the code where "input" becomes "data".

Medium

The ValidationPipe options that change behaviour

Flag
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,              // strip properties with no decorator
    forbidNonWhitelisted: false,  // …or 400 instead of stripping
    transform: true,              // hand the handler a real DTO instance
    forbidUnknownValues: true,    // 400 on an object with no validation metadata (Nest defaults this to false)
  }),
);

whitelist is a security control, not tidiness

Without it, extra keys survive into your handler. That matters the moment anything downstream does a bulk assignment:

// body: { "email": "a@b.c", "role": "admin", "credits": 999999 }
await this.repo.update(id, dto);   // ❌ mass assignment: role/credits go straight through

whitelist: true deletes every property that has no validation decorator, so a DTO with only email yields { email }. The stricter forbidNonWhitelisted: true rejects the request instead — better for internal APIs where an unknown field means the client is wrong, worse for public APIs where you want forward compatibility.

What transform does — and doesn't

Three distinct effects, easy to blur together:

  • The handler receives an instance of the DTO class, not a plain object — so DTO methods and class-transformer @Expose/@Type behaviour work. (The pipe builds that instance to validate either way; transform only decides whether your handler gets it.)
  • A named path or query param declared number or boolean is cast: @Query("page") page: number turns "2" into 2 — and "abc" into NaN, with no error. It's a cast, not a check.
  • Body DTO properties are not converted. { "quantity": "3" } against @IsInt() quantity is still a 400. A property converts only when it asks to: an explicit @Type(() => Number), or transformOptions: { enableImplicitConversion: true } on the pipe.

Implicit conversion is convenient and surprising in two ways, both because it converts before the validators run. A boolean property gets Boolean(value), and Boolean("false") is true. A string property turns 42 into "42", so @IsString() stops catching numbers. Prefer the explicit @Type(() => Number) on query DTOs, where the input genuinely is always a string, and an explicit @Transform for booleans — @Type(() => Boolean) makes the same Boolean("false") mistake.

The status codes are part of the contract

  • 400 — the body/params failed validation. ValidationPipe throws BadRequestException with a message array of human-readable failures. (Nest 12 adds errorFormat: "grouped", which keys the messages by property path instead.)
  • 422 — sometimes preferred for "syntactically fine, semantically invalid". Pick one and be consistent; Nest's default is 400.
  • 500 — what you get when validation didn't happen and the handler blew up on undefined. A 500 on bad input is a missing-validation bug.

Return all failures, not the first one: a client fixing a form one field per round-trip is a bad API. class-validator collects them; a hand-rolled validator has to be written to.

Keep response shape separate from request shape

CreateUserDto and the user you return are different contracts — the response must never leak passwordHash. Two options:

// class-transformer, applied by an interceptor
@Exclude() passwordHash: string;
// main.ts: app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

…or an explicit toResponse()/mapper that builds the response object field by field. The mapper is more code and impossible to get wrong by forgetting a decorator — which is why many teams prefer it for anything sensitive.

Complex
1 / 2

Nested objects and PATCH — what slips past the decorators

Flag

Nested objects need two decorators

class-validator doesn't recurse by default, and class-transformer can't know which class a nested plain object should become:

export class AddressDto {
  @IsString() street: string;
  @Matches(/^\d{5}$/) zip: string;
}

export class CreateCustomerDto {
  @IsString() name: string;

  @ValidateNested()          // recurse into it
  @Type(() => AddressDto)    // …as an AddressDto
  address: AddressDto;

  @ValidateNested({ each: true })
  @Type(() => AddressDto)
  @ArrayMaxSize(5)
  otherAddresses: AddressDto[];
}

@Type doesn't need transform: true: ValidationPipe always converts the body to class instances before validating, and transform only decides whether the handler receives them.

Forget @ValidateNested and the whole subtree is skipped. Forget @Type and the nested value stays a plain object, so its decorators are never found — silently unvalidated. With whitelist: true it's worse: the plain object's keys count as undecorated and get stripped, so the handler receives address: {}. This pair is the single most common validation hole in a Nest codebase.

PATCH: derive, don't duplicate

export class UpdateOrderDto extends PartialType(CreateOrderDto) {}

PartialType (from @nestjs/mapped-types, or @nestjs/swagger if you use Swagger) copies every property and its decorators, adding @IsOptional(). PickType, OmitType and IntersectionType cover the rest. Hand-copying a DTO for the update route means the day someone adds @MaxLength to create, PATCH quietly accepts a 10 000-character note.

Know exactly what @IsOptional() lets through: it skips every validator on the property when the value is undefined or null. So the derived DTO accepts { "customerId": null }, and a handler that writes the DTO straight to the row nulls a required column — a database error at best. To mean "may be absent, never null":

export class UpdateOrderDto extends PartialType(CreateOrderDto, { skipNullProperties: false }) {}

// on a hand-written DTO, the same rule:
@ValidateIf((_, value) => value !== undefined)
@IsString()
note?: string;

skipNullProperties: false makes PartialType add that @ValidateIf instead of @IsOptional(), so null meets the original validators and fails them. And with whitelist: true, "absent" and "present but stripped" look identical to your handler, which matters when your update is a partial write.

Quiz

A handler is create(@Body() dto: CreateOrderDto) and the DTO's fields carry @IsUUID() / @IsInt(). A request with quantity: "lots" reaches the handler unvalidated. What's missing?

Flag

Why is whitelist: true a security control rather than a tidiness setting?

Flag

The app registers new ValidationPipe({ whitelist: true, transform: true }) globally. A handler takes @Query("page") page: number, and a client sends ?page=abc. What does the handler receive?

Flag

CreateCustomerDto has an address object whose DTO carries decorators, but bad addresses are accepted. Which pair of decorators is required on the parent's field?

Flag

Signup must reject an email that's already registered. Where does that check belong, and what status?

Flag

Why derive the PATCH DTO with class UpdateOrderDto extends PartialType(CreateOrderDto) {} instead of writing it out?

Flag

Your user entity has passwordHash. What's the most robust way to keep it out of responses?

Flag

Fill in the blank

Make validation the application-wide default: strip undeclared properties and hand the handler real DTO instances.

Flag
app.useGlobalPipes(
  new ValidationPipe({ : true, : true }),
);

Allow note to be absent, but cap its length when present.

Flag
@()
@IsString()
@(280)
note?: string;

Validate a nested address object with its own DTO.

Flag
@()
@(() => AddressDto)
address: AddressDto;

Coerce a query-string page number into an actual number inside a query DTO.

Flag
export class ListOrdersQueryDto {
  @IsOptional()
  @(() => Number)
  @IsInt()
  @Min(1)
  page?: number;
}

Derive the update DTO from the create DTO so every field becomes optional but keeps its constraints.

Flag
export class UpdateOrderDto extends (CreateOrderDto) {}

Challenge

Make the validation pipe behave like Nest's

Flag

validation-pipe.ts is a hand-rolled stand-in for new ValidationPipe({ whitelist: true, transform: true }). order-dto.ts holds the "DTO": the same metadata @IsInt() / @Min(1) / @IsOptional() would carry, as a plain object (the sandbox can't run decorators).

createOrderSchema = {
  customerId: { type: "string" },
  quantity: { type: "int", min: 1, max: 100 },
  express: { type: "boolean", optional: true },
  note: { type: "string", optional: true, maxLength: 10 },
};

One thing the schema says that a bare class wouldn't: int and boolean fields also declare their conversion from a string. In real Nest, transform: true on its own never converts a body DTO's properties — quantity: "3" against @IsInt() is a 400 — so the DTO would carry @Type(() => Number) on quantity and a @Transform on express (see the hints for why not @Type(() => Boolean)).

It currently boots and "works" — which is the point. Four behaviours are missing, and each one is a real production bug:

  1. whitelist — the returned value is a copy of the raw body, so role: "admin" reaches the handler and any bulk update writes it.
  2. transform — a form-encoded body sends every field as text, so quantity arrives as "3"; a field that declares its conversion should come out as 3, not be rejected. A string that doesn't convert cleanly ("two", "3abc", or "yes" for a boolean) must still fail — never pass through as NaN or a guess.
  3. every failure — it returns after the first problem, so the client fixes one field per round-trip.
  4. required fields — it only inspects keys the client sent, so an omitted non-optional field is never checked. Missing input is a 400, not a 500 later on.

The fix is mostly one decision: iterate the schema, not the body.

Your task

Edit validation-pipe.ts so the tests pass:

  • A valid body is accepted, and value holds the parsed fields
  • Keys not declared in the schema are absent from value
  • "3" → 3 for an int field, and "true" / "false" → true / false for a boolean field
  • A body with three bad fields reports three errors
  • A missing non-optional field fails with status: 400 and names the field
  • An absent optional field stays absent (no undefined/null key)
  • A string that doesn't convert cleanly ("two", "3abc", "yes" for a boolean) fails and names the field

Loading the in-browser editor…

Further reading