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
ValidationPiperuns before controller code, converts plain JSON into typed class instances, and strips extra fields- Always pair DTOs with
class-validatordecorators and a globalValidationPipe - Without
whitelist: true, extra fields likerole: "admin"pass straight through to your service - Decision rule: public API endpoint gets a DTO; internal service call uses a plain interface
Quick example
// 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 CreatedValidationPipe 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
// 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: GetUsersDtoworks 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
// Request: GET /users?age=25
export class GetUsersDto {
@IsInt() age: number; // fails - "25" is a string without transform
}
// Fix: set transform: true in ValidationPipeQuery 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
// 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 propsThis 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()
// 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
// 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() combined5. 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
// 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
// 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
// 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 readyA concise answer to help you respond confidently on this topic during an interview.