Validation and DTOs
Never trust the request. Define DTOs as the shape of your boundary, validate them with Zod or class-validator, return useful errors, and parse into types so the rest of the code can't receive unchecked data.
🎯 What you'll learn
- Never trust the request
- DTOs: the shape of the boundary
- Zod schemas and Nest's ValidationPipe
- Parse, don't validate; good errors; validation layers
📚 Before you start
1Never Trust the Request
Everything in a request — body, query string, path params, headers — is attacker-controlled text. TypeScript types do not exist at run time, so this is a lie:
app.post("/users", (req, res) => {
const body = req.body as CreateUserInput; // compiles... and checks nothing
body.age.toFixed(0); // 💥 if age is "abc", missing, or an object
});Validation turns untrusted unknown input into a value you can rely on — or rejects it with a 400 before any business logic runs.
2DTOs: the Shape of the Boundary
A DTO (Data Transfer Object) describes exactly what crosses the API boundary for one operation. It is deliberately not your database entity:
// entity: what the database stores
type User = { id: string; email: string; passwordHash: string; role: "user" | "admin"; createdAt: Date };
// input DTO: what a client may SEND to create a user
type CreateUserDto = { email: string; password: string; name?: string };
// output DTO: what the API RETURNS
type UserResponseDto = { id: string; email: string; name?: string };| Why separate DTOs | What goes wrong without them |
|---|---|
| input DTO lists only writable fields | mass assignment: client sends "role": "admin" and it's saved |
| output DTO lists only public fields | passwordHash leaks in a JSON response |
| one DTO per operation | create vs update vs patch have different required fields |
| decouples API from schema | renaming a DB column doesn't break clients |
3Schema Validation with Zod
Schema libraries describe the DTO once and derive both the run-time check and the static type:
import { z } from "zod";
const CreateUserSchema = z.object({
email: z.string().email().toLowerCase(),
password: z.string().min(12),
name: z.string().trim().max(80).optional(),
age: z.coerce.number().int().min(13).optional(), // "42" → 42 (query strings are always strings)
}).strict(); // unknown keys → error (blocks mass assignment)
type CreateUserDto = z.infer<typeof CreateUserSchema>;
app.post("/users", (req, res) => {
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.flatten().fieldErrors });
}
const dto: CreateUserDto = parsed.data; // validated AND typed
// ...
});4NestJS: class-validator + ValidationPipe
import { IsEmail, IsOptional, IsString, MinLength, MaxLength } from "class-validator";
export class CreateUserDto {
@IsEmail() email!: string;
@IsString() @MinLength(12) password!: string;
@IsOptional() @IsString() @MaxLength(80) name?: string;
}
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // strip properties with no decorator
forbidNonWhitelisted: true, // ...or reject them with 400
transform: true, // turn the plain object into a CreateUserDto instance, coerce types
}));
@Post()
create(@Body() dto: CreateUserDto) { /* dto is already validated */ }The ValidationPipe runs in the pipes stage of the lifecycle (07-request_response_lifecycle/) — after guards, right before the handler.
5Parse, Don't Validate
There are two styles:
- validate:
isValid(input): booleanand then keep using the untypedinput - parse:
parse(input: unknown): Dto— returns a new, typed, normalized value or throws
Parsing is safer: downstream code can only get a Dto by going through the parser, the value is normalized once (trimmed, lowercased, coerced), and the type system tracks that it's been checked.
6Good Validation Errors
HTTP/1.1 400 Bad Request
{
"error": "validation_failed",
"details": [
{ "field": "email", "message": "must be a valid email" },
{ "field": "password", "message": "must be at least 12 characters" }
]
}- Report all field errors at once, not just the first.
- Use stable field paths (
items[2].quantity) so clients can highlight inputs. - Don't echo raw input back into HTML (XSS) or include internal details.
backend/api-design/09-error_response_designcovers the full error format.
7Validation Layers
| Layer | Checks | Example |
|---|---|---|
| Syntactic (DTO) | shape, types, formats, lengths | email is a string and an email |
| Semantic / business (service) | rules needing data or context | email not already registered; quantity ≤ stock |
| Database constraints | last line of defense | UNIQUE(email), CHECK (quantity > 0) |
DTO validation does not replace business-rule validation — it just makes sure the service receives well-formed input.
Under the HoodHow Decorator Validation Finds Its Rules▾
@IsEmail() is a function that runs once, when the class is defined — not when a request arrives. It registers metadata { target: CreateUserDto, property: "email", constraint: "isEmail" } into a global MetadataStorage. At request time ValidationPipe:
- reads the parameter's design type (
CreateUserDto) that TypeScript emitted viaemitDecoratorMetadata/reflect-metadata, - calls
plainToInstance(CreateUserDto, body)(class-transformer) to build an instance, - calls
validate(instance), which looks up every constraint registered for that class and runs them, - throws
BadRequestExceptionwith the collected errors if any failed.
That's why DTOs must be classes, not interfaces or type aliases: interfaces are erased at compile time, leaving no run-time object to hang metadata on. Zod takes the opposite approach — the schema is a run-time object, and the type is derived from it with z.infer, so there's no reflection involved at all.
⚠️ Common Pitfalls
- Casting instead of validating (
req.body as Dto) — compiles, checks nothing. - Reusing the entity as the input DTO — enables mass assignment of
role,id,balance. - Returning the entity directly — leaks
passwordHashand internal columns. - Forgetting coercion for query/path params — they are always strings;
"10" > "9"isfalse. - No size limits — unbounded strings/arrays let a single request burn memory and CPU.
- Validating only on the client — anyone can call the API directly with curl.
📋 Quick Reference
| Need | Zod | class-validator |
|---|---|---|
| define DTO | z.object({...}) | class with decorators |
| static type | z.infer<typeof S> | the class itself |
| reject unknown keys | .strict() | forbidNonWhitelisted: true |
| strip unknown keys | default .object() (strips) | whitelist: true |
| coerce strings | z.coerce.number() | transform: true + @Type(() => Number) |
| check | S.safeParse(x) | ValidationPipe / validate() |
🧠 Key takeaways
- A DTO describes exactly what crosses the boundary (input and output).
- Zod gives runtime validation + inferred types; Nest's ValidationPipe + class-validator does it with decorators.
- Use whitelist/strict to strip or reject unknown fields; coerce types explicitly.
- Parse into types at the edge; validate shape at the boundary and business rules in the domain.
🎤 Interview Q&A
Q1What is a DTO and why use one?core▾
A Data Transfer Object defines the shape of data crossing a boundary (API request/response). It decouples the API contract from domain/persistence models, controls exposed fields, and is the place to declare validation rules.
- Should DTOs have behaviour? No, they're plain data carriers.
Q2How do validation libraries compare across stacks?core▾
TypeScript: Zod (schema-first, infers types), class-validator (decorators, NestJS). .NET: DataAnnotations, FluentValidation. Python: Pydantic (FastAPI). All validate and produce structured errors.
- Why is Zod popular? One schema gives runtime validation and static types.
Q3Separate DTOs for create, update and response?core▾
Usually yes: create DTOs require fields, update DTOs make them optional (PATCH), response DTOs include server-generated fields (id, timestamps) and exclude secrets. Sharing one class leads to leaks and confusing validation.
- NestJS helper?
PartialType(CreateDto)for update DTOs.
Q4How do you handle validation that needs the database?senior▾
Format validation at the boundary; existence/uniqueness checks in the service (with DB constraints as the final guarantee against races), returning domain errors mapped to 404/409.
- Why not only in a validator? Race conditions: two requests can pass a uniqueness check simultaneously.
Q5What's the risk of binding requests directly to entities?core▾
Mass assignment/over-posting: clients set fields they shouldn't (role=admin, price), and internal fields get exposed in responses.
- Famous case? GitHub's 2012 mass assignment vulnerability in Rails.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.