Skip to main content

What are dtos and how does validation work in NestJS?

DTO (Data Transfer Object) in NestJS is a TypeScript class that defines the expected shape and validation rules for incoming API data, checked automatically by ValidationPipe before the request reaches your controller.

Theory

TL;DR

  • DTOs are a checkpoint for your API: the request body must match the declared shape and rules, or it gets rejected with a 400 error
  • ValidationPipe runs before controller code, converts plain JSON into typed class instances, and strips extra fields
  • Always pair DTOs with class-validator decorators and a global ValidationPipe
  • Without whitelist: true, extra fields like role: "admin" pass straight through to your service
  • Decision rule: public API endpoint gets a DTO; internal service call uses a plain interface

Quick example

typescript
// create-user.dto.ts import { IsEmail, IsString, MinLength } from 'class-validator'; export class CreateUserDto { @IsEmail() email: string; @IsString() @MinLength(6) password: string; } // users.controller.ts @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); // already validated here } // POST /users { "email": "test@example.com", "password": "short" } // → 400 { message: ['password must be longer than 6 characters'] } // POST /users { "email": "test@example.com", "password": "secret123" } // → 201 Created

ValidationPipe converts the raw JSON body into a CreateUserDto instance and checks every decorator. If anything fails, NestJS throws a BadRequestException before your code runs.

How ValidationPipe works

ValidationPipe sits between the HTTP layer and your controllers. When a request arrives, it goes through four steps.

First, class-transformer converts the plain JSON object into a real class instance. With transform: true set, the string "25" becomes the number 25. Second, class-validator reads the decorator metadata through TypeScript reflection and checks each rule. Third, on failure, NestJS throws a BadRequestException with a detailed list of messages. Fourth, on success, the typed object lands in your controller parameter.

This requires emitDecoratorMetadata: true in tsconfig.json and the reflect-metadata polyfill. NestJS CLI projects have both by default.

Global setup

typescript
// main.ts app.useGlobalPipes(new ValidationPipe({ whitelist: true, // strip properties not declared in DTO forbidNonWhitelisted: true, // 400 if extra props are sent transform: true, // string "25" → number 25 }));

All three options together give you what most teams actually want in production. Without transform: true, query params and URL segments stay as strings, so @IsInt() will fail on age=25 from a URL.

When to use

  • Public API endpoint → DTO with decorators and ValidationPipe
  • Update endpoint → PartialType(CreateUserDto) makes all fields optional automatically
  • Query params → @Query() dto: GetUsersDto works the same as @Body()
  • Internal service call → plain TypeScript interface, no validation needed
  • File upload → DTO for metadata only; the file itself goes through FileInterceptor

Common mistakes

1. Forgetting transform: true

typescript
// Request: GET /users?age=25 export class GetUsersDto { @IsInt() age: number; // fails - "25" is a string without transform } // Fix: set transform: true in ValidationPipe

Query params and path variables arrive as strings. Without transform: true, @IsInt() rejects them even when the value looks like a number.

2. No whitelist: true

typescript
// Client sends: { email: "user@x.com", role: "admin" } // Without whitelist: true → role: "admin" reaches your service // Fix: whitelist: true strips unknown props automatically // Stronger fix: forbidNonWhitelisted: true returns 400 on extra props

This is the most common security gap I've seen in NestJS codebases. One stray property and you've handed someone an unintended escalation path.

3. Nested DTOs without @Type()

typescript
// Wrong - items stays a plain object array, not OrderItemDto instances @ValidateNested({ each: true }) items: OrderItemDto[]; // Correct @ValidateNested({ each: true }) @Type(() => OrderItemDto) items: OrderItemDto[];

class-transformer needs @Type() to know what class to instantiate. Without it, validation on nested objects does not run at all and bad data passes through.

4. @IsOptional() on required fields

typescript
// Wrong - accepts email: "" (empty string) @IsOptional() @IsEmail() email: string; // If email is required, drop @IsOptional() // If it can be absent but must be valid when present, keep it, // but know that "" passes @IsOptional() + @IsEmail() combined

5. Missing emitDecoratorMetadata in tsconfig

If decorators seem to do nothing, check tsconfig.json. Both experimentalDecorators: true and emitDecoratorMetadata: true are required. NestJS CLI adds them, but projects configured manually often miss the second one.

Real-world usage

  • NestJS + Prisma: DTO validates before prisma.user.create(), so the database never receives invalid data
  • NestJS + Swagger: add @ApiProperty() to DTO fields and OpenAPI docs generate automatically
  • Microservices: DTOs define message contracts between services in NestJS hybrid apps
  • Frontend integration: OpenAPI spec generated from DTOs can produce TypeScript types for React forms via tools like openapi-typescript

Follow-up questions

Q: What happens if you skip ValidationPipe entirely?
A: The raw JSON body arrives in your controller as any. TypeScript types are erased at runtime, so the shape annotations on your DTO do nothing. You would need to validate manually at every endpoint.

Q: How does class-validator find the decorator rules at runtime?
A: Via TypeScript metadata reflection. reflect-metadata stores decorator data attached to each class property, and class-validator reads it when validate() runs. This is why emitDecoratorMetadata: true is not optional.

Q: What is the difference between @IsDefined() and @IsNotEmpty()?
A: @IsDefined() only rejects null and undefined. @IsNotEmpty() also rejects empty string "" and empty array []. For most string fields, @IsNotEmpty() is what you actually want.

Q: Can you validate query params the same way?
A: Yes. @Query() dto: GetUsersDto with ValidationPipe behaves identically to @Body(). Just make sure transform: true is set, since query param values are always strings.

Q (senior): Explain validation groups and a custom exceptionFactory.
A: Validation groups let you apply decorators conditionally. @IsUnique({ groups: ['create'] }) runs only when you pass groups: ['create'] to ValidationPipe, useful in multi-step wizards. The exceptionFactory option replaces the default 400 response with a custom shape, which matters when clients expect a specific error contract.

Examples

Basic: user registration

typescript
// dto/create-user.dto.ts import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsEnum } from 'class-validator'; export enum UserRole { USER = 'user', ADMIN = 'admin' } export class CreateUserDto { @IsString() @MinLength(2) @MaxLength(50) name: string; @IsEmail() email: string; @IsOptional() @IsEnum(UserRole) role?: UserRole = UserRole.USER; } // users.controller.ts @Post() create(@Body() dto: CreateUserDto) { return this.usersService.create(dto); }

A valid request returns 201. A request with email: "not-an-email" gets 400 { message: ["email must be an email"] }. The response also tells you exactly which field failed and why.

Intermediate: e-commerce order with nested items

typescript
// dto/create-order.dto.ts import { IsUUID, IsInt, Min, IsPositive, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; export class OrderItemDto { @IsUUID() productId: string; @IsInt() @Min(1) quantity: number; } export class CreateOrderDto { @ValidateNested({ each: true }) @Type(() => OrderItemDto) items: OrderItemDto[]; @IsPositive() total: number; } // orders.controller.ts @Post() create(@Body() dto: CreateOrderDto) { return this.ordersService.placeOrder(dto); }

Send items: [{ productId: "not-a-uuid", quantity: 0 }] and the response is 400 { message: ["items.0.productId must be a UUID", "items.0.quantity must not be less than 1"] }. The path in the error message tells you exactly which item in the array failed.

Advanced: custom validation decorator

typescript
// decorators/is-slug.decorator.ts import { registerDecorator, ValidationOptions } from 'class-validator'; export function IsSlug(options?: ValidationOptions) { return (object: object, propertyName: string) => { registerDecorator({ name: 'isSlug', target: object.constructor, propertyName, options, validator: { validate(value: unknown) { return typeof value === 'string' && /^[a-z0-9-]+$/.test(value); }, defaultMessage: () => `${propertyName} must contain only lowercase letters, numbers, and hyphens`, }, }); }; } // dto/create-post.dto.ts export class CreatePostDto { @IsString() title: string; @IsSlug() slug: string; // "my-post-title" passes, "My Post Title" fails }

Custom decorators follow the same registration pattern as built-in ones. For async validation, like checking if an email already exists in the database, return Promise<boolean> from validate(). ValidationPipe handles async validators automatically.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?