DTOs & validation: the contract at the edge
- 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
The DTO is the boundary, not a type
FlagA 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".
The ValidationPipe options that change behaviour
Flagapp.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/@Typebehaviour work. (The pipe builds that instance to validate either way;transformonly decides whether your handler gets it.) - A named path or query param declared
numberorbooleanis cast:@Query("page") page: numberturns"2"into2— and"abc"intoNaN, with no error. It's a cast, not a check. - Body DTO properties are not converted.
{ "quantity": "3" }against@IsInt() quantityis still a 400. A property converts only when it asks to: an explicit@Type(() => Number), ortransformOptions: { 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.
ValidationPipethrowsBadRequestExceptionwith amessagearray of human-readable failures. (Nest 12 addserrorFormat: "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.
Nested objects and PATCH — what slips past the decorators
FlagNested 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?
Why is whitelist: true a security control rather than a tidiness setting?
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?
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?
Signup must reject an email that's already registered. Where does that check belong, and what status?
FlagWhy derive the PATCH DTO with class UpdateOrderDto extends PartialType(CreateOrderDto) {} instead of writing it out?
Your user entity has passwordHash. What's the most robust way to keep it out of responses?
Fill in the blank
Make validation the application-wide default: strip undeclared properties and hand the handler real DTO instances.
Flagapp.useGlobalPipes( new ValidationPipe({ : true, : true }), );
Allow note to be absent, but cap its length when present.
@() @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.
Flagexport 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.
Flagexport class UpdateOrderDto extends (CreateOrderDto) {}
Challenge
Make the validation pipe behave like Nest's
Flagvalidation-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:
- whitelist — the returned value is a copy of the raw body, so
role: "admin"reaches the handler and any bulk update writes it. - transform — a form-encoded body sends every field as text, so
quantityarrives as"3"; a field that declares its conversion should come out as3, not be rejected. A string that doesn't convert cleanly ("two","3abc", or"yes"for a boolean) must still fail — never pass through asNaNor a guess. - every failure — it returns after the first problem, so the client fixes one field per round-trip.
- 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
valueholds the parsed fields - Keys not declared in the schema are absent from
value "3"→3for anintfield, and"true"/"false"→true/falsefor abooleanfield- A body with three bad fields reports three errors
- A missing non-optional field fails with
status: 400and names the field - An absent optional field stays absent (no
undefined/nullkey) - A string that doesn't convert cleanly (
"two","3abc","yes"for a boolean) fails and names the field