What is a DTO in NestJS, and why is it a class and not an interface?
A DTO (data transfer object) describes the shape of data crossing the API boundary: what a request body must contain or what a response returns.
export class CreateOrderDto { @IsString() productId: string; @IsInt() @Min(1) quantity: number; }It is a class because classes exist at runtime.
ValidationPipeuses the parameter's class (from decorator metadata) to know which rules to apply, and class-validator's decorators attach those rules to the class. An interface is erased by the compiler, so@Body() dto: CreateOrderInterfacewould receive the body unchecked. Keep DTOs separate from database entities, so a new column never becomes a writable API field by accident.How do you validate a request body in NestJS?
Decorate a DTO class with class-validator rules and enable
ValidationPipe. Invalid input never reaches the handler: the pipe answers400with every failed rule.NestJSimport { Body, Controller, Get, Module, Post, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { IsEmail, IsInt, IsString, Min, MinLength } from 'class-validator'; class CreateUserDto { @IsString() @MinLength(2) name: string; @IsEmail() email: string; @IsInt() @Min(13) age: number; } // GET /users @Controller('users') class UsersController { private users: CreateUserDto[] = []; @Get() all() { return this.users; } @Post() create(@Body() dto: CreateUserDto) { this.users.push(dto); return { saved: dto, isDto: dto instanceof CreateUserDto }; } } @Module({ controllers: [UsersController] }) class AppModule {} async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true })); await app.listen(3000); } bootstrap();Send
POST /userswith{"name": "A", "email": "nope", "age": "20"}: the400lists four messages, andagefailsIsIntbecause"20"is a string. With{"name": "Ada", "email": "ada@example.com", "age": 36}you get201andisDto: true, sincetransformturned the plain body into aCreateUserDtoinstance.What do
whitelistandforbidNonWhitelisteddo inValidationPipe?whitelist: truestrips every property that has no validation decorator in the DTO.forbidNonWhitelisted: truerejects the request with400instead of silently stripping.They prevent mass assignment: a client sending
{"name": "Ada", "isAdmin": true}to an endpoint that saves the body must not become an admin because the entity happens to have anisAdmincolumn. In theValidationPipeexample on this page, adding"isAdmin": trueto the body returns400withproperty isAdmin should not exist.A property with no rule of its own needs
@Allow()or@IsOptional()to survive the whitelist.What does
transform: truedo, and why does"20"still fail@IsInt()?transform: truemakes the pipe return an instance of the DTO class instead of the plain object, and converts route params and query values to the declared primitive type:@Param('id') id: numberarrives as a number.It does not convert values inside a JSON body. A body field typed
numberthat the client sends as"20"is still a string, so@IsInt()fails. Two ways to accept it:// per field @Type(() => Number) @IsInt() age: number; // everywhere (looser) new ValidationPipe({ transform: true, transformOptions: { enableImplicitConversion: true } });Implicit conversion is convenient for query DTOs but surprising for booleans: the string
"false"converts totrue.What are pipes in NestJS? Name the built-in ones.
A pipe transforms or validates a handler argument before the handler runs. If it throws, the handler is never called. Built-in pipes:
ValidationPipe,ParseIntPipe,ParseFloatPipe,ParseBoolPipe,ParseArrayPipe,ParseUUIDPipe,ParseEnumPipe,ParseDatePipe,DefaultValuePipe,ParseFilePipe, and in NestJS 12StandardSchemaValidationPipe.NestJSimport { Controller, Get, Module, Param, ParseIntPipe, Query, DefaultValuePipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; // GET /products/abc @Controller('products') class ProductsController { @Get() list(@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number) { return { page, type: typeof page }; } @Get(':id') one(@Param('id', ParseIntPipe) id: number) { return { id, type: typeof id }; } } @Module({ controllers: [ProductsController] }) class AppModule {} async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); } bootstrap();/products/abcanswers400withValidation failed (numeric string is expected). SendGET /products/42: theidarrives as a number.GET /productsusesDefaultValuePipe(1), and?page=xfails like the first request.How do you write a custom pipe?
Implement
PipeTransform: return the (possibly transformed) value, or throw to reject the request.@Injectable() export class ParseSlugPipe implements PipeTransform<string, string> { transform(value: string, metadata: ArgumentMetadata) { const slug = value.trim().toLowerCase(); if (!/^[a-z0-9-]{3,60}$/.test(slug)) { throw new BadRequestException(`Invalid ${metadata.data}: ${value}`); } return slug; } } @Get(':slug') find(@Param('slug', ParseSlugPipe) slug: string) {}metadatatells you where the value came from (typeisbody,query,paramorcustom;datais the key). Pass the class, not an instance, when the pipe needs injected services.How do you validate nested objects and arrays?
Mark the property with
@ValidateNested()and tell class-transformer which class to build with@Type(). Without@Type, the nested value stays a plain object and its rules never run.class AddressDto { @IsString() city: string; @IsPostalCode('any') zip: string; } class CreateCustomerDto { @ValidateNested() @Type(() => AddressDto) address: AddressDto; @IsArray() @ArrayMaxSize(5) @ValidateNested({ each: true }) @Type(() => AddressDto) otherAddresses: AddressDto[]; @IsString({ each: true }) tags: string[]; }{ each: true }applies a rule to every element of an array, set or map.How do you create an update DTO without repeating the create DTO?
Use the mapped type helpers:
PartialTypemakes every property optional and keeps the validation rules,PickTypeandOmitTypeselect properties, andIntersectionTypemerges two DTOs.import { OmitType, PartialType, PickType } from '@nestjs/mapped-types'; export class UpdateUserDto extends PartialType(CreateUserDto) {} export class LoginDto extends PickType(CreateUserDto, ['email', 'password']) {} export class PublicUserDto extends OmitType(CreateUserDto, ['password']) {}Import them from
@nestjs/swaggerinstead when you generate OpenAPI docs, so the schema follows too. Unlike TypeScript'sPartial<T>, these produce real classes that carry the validation metadata.Can you validate with Zod instead of class-validator in NestJS?
Yes. In NestJS 12,
@Body(),@Query()and@Param()take a{ schema }option for any Standard Schema library (Zod, Valibot, ArkType), andStandardSchemaValidationPipevalidates it. Before 12, the usual way was a custom pipe callingschema.parse().NestJSimport { Body, Controller, Get, Module, Post, Query, StandardSchemaValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { z } from 'zod'; const SearchQuery = z.object({ q: z.string().min(2), limit: z.coerce.number().int().max(50).default(10), }); // GET /search?q=nest&limit=5 @Controller('search') class SearchController { @Get() find(@Query({ schema: SearchQuery }) query: z.infer<typeof SearchQuery>) { return { query, limitType: typeof query.limit }; } } @Module({ controllers: [SearchController] }) class AppModule {} async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new StandardSchemaValidationPipe()); await app.listen(3000); } bootstrap();The first request returns
limitas a number:z.coerceconverted the query string. SendGET /search?q=n&limit=500to get a400that lists both issues with their paths. Droplimitfrom the URL and the schema's default of10applies.How do you hide a field such as a password hash from API responses?
Return a class instance whose sensitive fields carry
@Exclude()and enableClassSerializerInterceptor, which runs class-transformer'sinstanceToPlainon the result.NestJSimport { ClassSerializerInterceptor, Controller, Get, Module, UseInterceptors } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { Exclude, Expose } from 'class-transformer'; class UserEntity { id: number; email: string; @Exclude() passwordHash: string; constructor(data: Partial<UserEntity>) { Object.assign(this, data); } @Expose() get handle() { return this.email.split('@')[0]; } } // GET /me @Controller() @UseInterceptors(ClassSerializerInterceptor) class MeController { @Get('me') me() { return new UserEntity({ id: 1, email: 'ada@example.com', passwordHash: 'x9$k' }); } @Get('raw') raw() { return { id: 1, email: 'ada@example.com', passwordHash: 'x9$k' }; } } @Module({ controllers: [MeController] }) class AppModule {} async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); } bootstrap();GET /meanswers withoutpasswordHashand with the computedhandle. SendGET /raw: a plain object literal passes through untouched and leaks the hash, the trap interviewers look for. Safer still is mapping entities to response DTOs explicitly, so a new column is never exposed by default.class-validator or Zod for validation in a NestJS project?
Both work in NestJS 12. The trade-off:
class-validator Zod (Standard Schema) Style Decorators on DTO classes Schema objects, types inferred Swagger @nestjs/swaggerreads DTO classesSupported in NestJS 12 through the schema Runtime type DTO class instance Plain parsed object Shared with frontend No (decorators, classes) Yes, the same schema Transform class-transformer,@Type()z.coerce,.transform()Pick one per codebase. class-validator matches most existing Nest code and docs; Zod fits a monorepo that shares schemas with a TypeScript frontend and avoids keeping a type and a validator in sync.
NestJS Validation, Pipes and DTO Interview Questions
DTOs, ValidationPipe options, built-in and custom pipes, Zod schemas and response serialization.