RizTech Academy logo
RizTech Academy
REST APIs Done ProperlyLesson 2 of 530 min

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

  1. Write AddToCartDto with all three rules. Send an invalid body and read the array of messages.
  2. Change it to an interface and confirm validation stops working entirely.
  3. Add custom messages and compare the output.
  4. Write CreateOrderDto with nested items. Send an item with quantity: 0 and confirm it is rejected.
  5. Remove @Type(() => OrderItemDto) and send the same bad item. Confirm it now passes — then explain why that is dangerous.
  6. Write QueryProductsDto. Send ?page=abc and read the error.
  7. Send ?inStock=false without @Transform and confirm it filters wrongly.
  8. Send an undeclared isAdmin: true field with whitelist off and log the DTO. Turn it on and confirm the field is gone.
  9. Derive an update DTO with PartialType and confirm partial updates work.
  10. 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