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

DTOs, class-validator and pipes

A DTO is a runtime class whose class-validator decorators describe a valid request; ValidationPipe reads that metadata before the handler, rejects bad input with 400, strips unknown props, and coerces types — so the handler trusts its args.

NEST Middle ◷ 16 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

A POST to /users arrives with { "email": 42, "isAdmin": true }. There is no validation, so the controller takes the body as-is, the service spreads it into a Prisma create, and now you have a user whose email is the number 42 and who quietly granted themselves admin — a field your API never meant to expose. The handler trusted the wire. It should never have had to: the contract was supposed to be enforced at the door, before a single line of business logic ran.

A DTO is a class, not an interface — because metadata must survive to runtime

A DTO (Data Transfer Object) describes the shape of a request body, query, or param. In Nest you write it as a class, never a TypeScript interface. The reason is concrete: interfaces are erased during compilation — they do not exist at runtime — and Nest’s validation works by reading decorator metadata off the object at runtime. A @IsEmail() decorator on an interface property has nothing to attach to once the types are stripped. A class survives transpilation as a real value, so the decorators (and the emitted design-type metadata) are there when the pipe asks.

You annotate each field with class-validator decorators that declare its constraints: @IsString, @IsEmail, @IsInt, @Min, @IsOptional, @ValidateNested, and dozens more. The DTO becomes a single, declarative source of truth for “what does a valid request look like” — readable, reusable, and testable in isolation.

import { IsEmail, IsString, IsInt, Min, IsOptional } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @Min(2) // length handled by @MinLength in real code; @Min shown for numbers below
  name: string;

  @IsInt()
  @Min(18)
  age: number;

  @IsOptional()
  @IsString()
  bio?: string;
}

The pipe runs before the handler — and decides what the handler ever sees

A pipe is a class with a transform() method that Nest runs on a route argument before the handler receives it. Pipes do two jobs: transformation (reshape or coerce the value) and validation (check it, throw if it fails). The built-in ValidationPipe is the one that ties DTOs together: it reads the class-validator metadata off your DTO, validates the incoming payload against it, and on failure throws a BadRequestException — an automatic 400 with the list of what was wrong. The handler only ever runs on input that already passed.

You enable it once, globally, in main.ts:

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,            // strip properties not in the DTO
      forbidNonWhitelisted: true, // ...or reject the request if they appear
      transform: true,            // coerce the plain object into a DTO instance + coerce primitives
    }),
  );
  await app.listen(3000);
}
bootstrap();

The controller then just declares the DTO as the type of its @Body() argument. No validation code lives in the controller — that is the point:

import { Controller, Post, Body, Get, Param, ParseIntPipe } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {
  @Post()
  create(@Body() dto: CreateUserDto) {
    // dto is already validated AND an instance of CreateUserDto.
    // Unknown fields like `isAdmin` were stripped (or the request was rejected).
    return this.users.create(dto);
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    // ParseIntPipe — a built-in pipe — coerces the string param to a number
    // and 400s if it isn't a valid integer. id is a real number here.
    return this.users.findOne(id);
  }
}

The three options that matter: whitelist, forbidNonWhitelisted, transform

These options are not cosmetic — two of them are a real security control.

  • whitelist: true strips any property of the payload that has no validation decorator in the DTO. The extra isAdmin: true from the hook simply never reaches your service.
  • forbidNonWhitelisted: true (use with whitelist) goes further: instead of silently stripping unknown props, it rejects the whole request with a 400 listing them. This is the stronger stance — it surfaces clients sending fields you do not accept rather than quietly dropping them.
  • transform: true does two things. It instantiates the DTO class from the plain JSON object (via class-transformer), so the value the handler gets is a real CreateUserDto instance, not a bare object — methods and instanceof work. It also coerces primitive types: a query param ?age=18 arriving as the string "18" becomes the number 18 when the DTO field is typed number.

Together, whitelist + forbidNonWhitelisted are your defence against mass-assignment / over-posting — the class of bug where a client sets a field (role, balance, isAdmin) the server never intended to be client-settable. Validating at the edge is what lets the rest of the application trust its inputs: once the pipe has run, every layer below it can treat the DTO as known-good.

Why this works

Why a global pipe rather than per-route? Registering ValidationPipe once in main.ts makes validation the default for every endpoint, so a new controller is safe by omission — you cannot forget to add it. The cost is that whitelist/forbid now apply everywhere, which is exactly what you want for a public API. For the rare endpoint that needs different rules, you override locally with a @UsePipes(new ValidationPipe({ ... })) on that handler. Beware one gotcha: transform: true only coerces primitives reliably when the design-type metadata is present, which is why tsconfig must have emitDecoratorMetadata and experimentalDecorators enabled.

The flow below is what every request now goes through: the pipe stands between the wire and your handler.

The table makes the difference concrete — the same noisy payload, with and without the pipe:

AspectNo ValidationPipeValidationPipe (whitelist + forbid + transform)
What reaches the handlerThe raw parsed JSON, untouchedA validated CreateUserDto instance
Unknown prop isAdminPasses straight through to the serviceStripped (whitelist) or request rejected (forbid)
Type coercion of ?age=18Stays the string “18”Coerced to the number 18
Invalid body (email: 42)Saved as-is; corrupt data downstreamAutomatic 400 with a per-field error list

Beyond ValidationPipe, Nest ships small built-in pipes for single scalar args — ParseIntPipe, ParseUUIDPipe, ParseBoolPipe, ParseArrayPipe — and you can write a custom pipe by implementing PipeTransform’s transform(value, metadata). But for request bodies the rule holds: put the constraints in the DTO, not in the controller, and let one global ValidationPipe enforce them.

Quiz

Why must a Nest DTO be a class and not a TypeScript interface?

Quiz

A client POSTs { email, name, isAdmin: true } but the DTO has no isAdmin field. ValidationPipe runs with whitelist: true. What happens?

Pick the best fit

A public POST endpoint must never let a client set a field the server didn't intend (mass-assignment), and bad bodies should fail loudly. Pick the ValidationPipe configuration.

Recall before you leave
  1. 01
    Walk through what ValidationPipe does to a request body, and where it sits relative to the handler.
  2. 02
    Why does whitelist + forbidNonWhitelisted matter for security, and why must the DTO be a class?
Recap

Validate input at the boundary so the rest of the app can trust it. A DTO is a runtime class — never an interface, because the class-validator decorators and design-type metadata the validator reads must survive compilation, and interfaces are erased. You annotate each field (@IsEmail, @IsInt, @Min, @IsOptional, @ValidateNested) to declare a valid request declaratively. A pipe runs before the handler to transform and validate an argument; the built-in ValidationPipe, enabled once with app.useGlobalPipes(new ValidationPipe({ ... })), reads the DTO metadata, rejects bad input with an automatic 400 plus a per-field error list, and otherwise hands the handler clean input. Its three load-bearing options: whitelist strips undecorated properties, forbidNonWhitelisted rejects requests that carry them (together closing the mass-assignment hole), and transform instantiates the DTO class and coerces primitives like "18" to 18. Beyond it, built-in pipes (ParseIntPipe and friends) and custom PipeTransform classes handle scalar args. The discipline that ties it together: keep constraints in the DTO, not the controller, and let one global pipe enforce them everywhere. Now when you see a controller method that opens with manual type checks or trusts its own req.body directly, you know exactly what to reach for.

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
Connected lessons

Something unclear?

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

Apply this

Put this lesson to work on a real build.

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.