Engineering Notes
Home/Backend/Frameworks/Validation and DTOs
Backend · Frameworks

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.

⚙️ Backend● Intermediate⏱ 5 min read▶ 1 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 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:

ts
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:

ts
// 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 DTOsWhat goes wrong without them
input DTO lists only writable fieldsmass assignment: client sends "role": "admin" and it's saved
output DTO lists only public fieldspasswordHash leaks in a JSON response
one DTO per operationcreate vs update vs patch have different required fields
decouples API from schemarenaming 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:

ts
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

ts
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): boolean and then keep using the untyped input
  • 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

json
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_design covers the full error format.

7Validation Layers

LayerChecksExample
Syntactic (DTO)shape, types, formats, lengthsemail is a string and an email
Semantic / business (service)rules needing data or contextemail not already registered; quantity ≤ stock
Database constraintslast line of defenseUNIQUE(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:

  1. reads the parameter's design type (CreateUserDto) that TypeScript emitted via emitDecoratorMetadata / reflect-metadata,
  2. calls plainToInstance(CreateUserDto, body) (class-transformer) to build an instance,
  3. calls validate(instance), which looks up every constraint registered for that class and runs them,
  4. throws BadRequestException with 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.

Untrusted JSON enters a schema parser that either returns a normalized, typed DTO for the service or a 400 response listing every field error; unknown keys are stripped or rejected
Untrusted JSON enters a schema parser that either returns a normalized, typed DTO for the service or a 400 response listing every field error; unknown keys are stripped or rejected

⚠️ Common Pitfalls

  1. Casting instead of validating (req.body as Dto) — compiles, checks nothing.
  2. Reusing the entity as the input DTO — enables mass assignment of role, id, balance.
  3. Returning the entity directly — leaks passwordHash and internal columns.
  4. Forgetting coercion for query/path params — they are always strings; "10" > "9" is false.
  5. No size limits — unbounded strings/arrays let a single request burn memory and CPU.
  6. Validating only on the client — anyone can call the API directly with curl.

📋 Quick Reference

NeedZodclass-validator
define DTOz.object({...})class with decorators
static typez.infer<typeof S>the class itself
reject unknown keys.strict()forbidNonWhitelisted: true
strip unknown keysdefault .object() (strips)whitelist: true
coerce stringsz.coerce.number()transform: true + @Type(() => Number)
checkS.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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. What does ValidationPipe's whitelist option do?
Unknown fields removed.
2. Main benefit of z.infer?
No drift.
3. Where should 'room must be available on those dates' be checked?
Business rule, needs state.
4. Query params arrive as strings. To get numbers…
At the boundary.