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.
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: truestrips any property of the payload that has no validation decorator in the DTO. The extraisAdmin: truefrom the hook simply never reaches your service.forbidNonWhitelisted: true(use withwhitelist) 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: truedoes two things. It instantiates the DTO class from the plain JSON object (via class-transformer), so the value the handler gets is a realCreateUserDtoinstance, not a bare object — methods andinstanceofwork. It also coerces primitive types: a query param?age=18arriving as the string"18"becomes the number18when the DTO field is typednumber.
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:
| Aspect | No ValidationPipe | ValidationPipe (whitelist + forbid + transform) |
|---|---|---|
| What reaches the handler | The raw parsed JSON, untouched | A validated CreateUserDto instance |
Unknown prop isAdmin | Passes straight through to the service | Stripped (whitelist) or request rejected (forbid) |
Type coercion of ?age=18 | Stays the string “18” | Coerced to the number 18 |
Invalid body (email: 42) | Saved as-is; corrupt data downstream | Automatic 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.
Why must a Nest DTO be a class and not a TypeScript interface?
A client POSTs { email, name, isAdmin: true } but the DTO has no isAdmin field. ValidationPipe runs with whitelist: true. What happens?
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.
- 01Walk through what ValidationPipe does to a request body, and where it sits relative to the handler.
- 02Why does whitelist + forbidNonWhitelisted matter for security, and why must the DTO be a class?
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.
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.