DTOs and validation
A DTO — data transfer object — declares the shape of a request and the rules its values must satisfy. It is the boundary between the outside world and your code, and getting it right removes an entire category of bug.
The problem
@Post()
create(@Body() body: any) {
return this.orders.create(body.items); // is items even an array?
}
any means no checks at all. body.items might be undefined, a string, or an
array of objects with the wrong fields — and you find out three functions later,
with a stack trace pointing at code that did nothing wrong.
A DTO
npm install class-validator class-transformer --workspace=apps/api
// apps/api/src/cart/dto/add-to-cart.dto.ts
import { IsInt, IsString, Max, Min, IsNotEmpty } from "class-validator";
export class AddToCartDto {
@IsString()
@IsNotEmpty({ message: "variantId is required" })
variantId!: string;
@IsInt({ message: "quantity must be a whole number" })
@Min(1, { message: "quantity must be at least 1" })
@Max(50, { message: "quantity cannot exceed 50" })
quantity!: number;
}
@Post("items")
add(@CurrentUser() user: User, @Body() dto: AddToCartDto) {
return this.cart.addItem(user.id, dto);
}
The ValidationPipe from module 5 enforces it before your method runs. By the
time addItem executes, variantId is a non-empty string and quantity is an
integer between 1 and 50.
Send something wrong:
{
"statusCode": 400,
"message": ["quantity must be at least 1", "variantId is required"],
"error": "Bad Request"
}
Every problem at once, not the first one. A client can show all the errors against their fields rather than making the user fix one per attempt.
It must be a class, not an interface. Decorators attach metadata at runtime, and interfaces do not exist at runtime. This trips up people who expect TypeScript types to be enough — they are erased at compile time and cannot validate anything.
The ! on each field tells TypeScript the value will be assigned even though
the constructor does not. Without strictPropertyInitialization off, you need
it.
The decorators worth knowing
| Decorator | Checks |
|---|---|
@IsString() @IsInt() @IsNumber() @IsBoolean() |
type |
@IsNotEmpty() |
not "", null or undefined |
@IsOptional() |
skip all other checks when absent |
@Min(n) @Max(n) |
numeric range |
@Length(min, max) @MaxLength(n) |
string length |
@IsEmail() @IsUrl() @IsUUID() |
format |
@IsEnum(MyEnum) |
one of an enum's values |
@IsIn([...]) |
one of a list |
@Matches(/regex/) |
pattern |
@IsArray() @ArrayMinSize(n) |
arrays |
@ValidateNested() |
validate objects inside |
@Type(() => Klass) |
tell the transformer the nested type |
Always pass a message. The defaults are readable to a developer and not
to a customer. "quantity must not be less than 1" is generated;
"Choose at least 1" is written for a person.
Nested objects
import { Type } from "class-transformer";
import { ArrayMinSize, IsArray, ValidateNested } from "class-validator";
export class OrderItemDto {
@IsString()
@IsNotEmpty()
variantId!: string;
@IsInt()
@Min(1)
quantity!: number;
}
export class CreateOrderDto {
@IsArray()
@ArrayMinSize(1, { message: "Your cart is empty" })
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items!: OrderItemDto[];
@IsString()
@IsNotEmpty({ message: "Choose a delivery address" })
addressId!: string;
@IsOptional()
@IsString()
@MaxLength(200)
note?: string;
}
@Type(() => OrderItemDto) is required and easy to forget. JSON has no
classes, so the transformer needs telling what to build. Without it,
@ValidateNested silently validates nothing — the request passes and you get
plain objects. A validation rule that quietly does nothing is worse than none,
so check nested DTOs actually reject bad input.
Query DTOs
import { Transform, Type } from "class-transformer";
export class QueryProductsDto {
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
@MaxLength(100)
q?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
minPaise?: number;
@IsOptional()
@Transform(({ value }) => value === "true" || value === true)
@IsBoolean()
inStock?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit: number = 20;
}
Query strings are always strings, so @Type(() => Number) converts before the
numeric checks run.
Booleans need @Transform, because Boolean("false") is true — the same
trap as Python's bool("False"). Without it, ?inStock=false filters to
in-stock items.
@Max(100) on limit is the clamp from the pagination lesson, now enforced
at the boundary instead of inside the service.
Defaults on the property give you page = 1 without any code.
The security part
From module 5, and worth repeating because it is the one that matters:
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })
Without whitelist, this request:
{ "variantId": "abc", "quantity": 2, "pricePaise": 1, "isAdmin": true }
arrives with pricePaise and isAdmin attached. Any code doing
prisma.user.update({ data: dto }) has just made the caller an administrator.
whitelist: true strips anything not declared. It is the difference between
a DTO being documentation and being a boundary.
The broader rule from module 1: the client sends identifiers, not values the
server should know. pricePaise has no business being in a request body, so
not declaring it means it cannot get through.
Separate DTOs per operation
export class CreateProductDto {
@IsString() @IsNotEmpty() name!: string;
@IsString() @IsNotEmpty() slug!: string;
@IsString() @IsNotEmpty() categoryId!: string;
}
export class UpdateProductDto extends PartialType(CreateProductDto) {}
PartialType from @nestjs/mapped-types makes every field optional, which is
what PATCH means.
Do not reuse one DTO for create and update. Create requires fields that
update does not, and sharing means one of them is wrong. Related helpers:
PickType, OmitType and IntersectionType.
Validating things the schema cannot
Some rules need the database:
- Does this variant exist?
- Is there enough stock?
- Does this address belong to this user?
Those belong in the service, not the DTO. A DTO validates shape and format; a service validates against state. Trying to do database checks in a custom validator means injecting services into decorators, which is possible and unpleasant.
The division: DTO answers "is this well-formed?", service answers "is this allowed right now?"
Documentation for free
export class AddToCartDto {
@ApiProperty({ example: "clx1abc", description: "The variant to add" })
@IsString()
variantId!: string;
}
With @nestjs/swagger, /api/docs shows every field, its type and its rules.
Because it is generated from the same decorators that enforce validation, the
documentation cannot disagree with the behaviour.
Check your work
Why a class and not an interface: decorators need runtime metadata, and interfaces are erased at compile time.
Why validation errors come back as an array: so the client can show every problem at once rather than one per attempt.
What @Type(() => Dto) does: tells the transformer which class to build for
nested objects. Without it, @ValidateNested validates nothing silently.
Why booleans need @Transform: "false" is a non-empty string, so a naive
conversion makes it true.
What whitelist: true prevents: undeclared properties reaching your code,
including ones that could escalate privileges if spread into a database update.
Why separate create and update DTOs: create requires fields update does not.
PartialType derives the update one.
Where database-dependent rules go: the service. A DTO checks shape; a service checks state.
Practice
- Write
AddToCartDtowith all three rules. Send an invalid body and read the array of messages. - Change it to an interface and confirm validation stops working entirely.
- Add custom messages and compare the output.
- Write
CreateOrderDtowith nested items. Send an item withquantity: 0and confirm it is rejected. - Remove
@Type(() => OrderItemDto)and send the same bad item. Confirm it now passes — then explain why that is dangerous. - Write
QueryProductsDto. Send?page=abcand read the error. - Send
?inStock=falsewithout@Transformand confirm it filters wrongly. - Send an undeclared
isAdmin: truefield withwhitelistoff and log the DTO. Turn it on and confirm the field is gone. - Derive an update DTO with
PartialTypeand confirm partial updates work. - Try to check stock availability in a DTO validator, then move it to the service and note which is simpler.
Next: errors a client can actually act on.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship