Validate request bodies, query params, and path params against DTO classes — declaratively, with one global pipe. The same class-transformer/class-validator pair powers serialization on the way out and validation on the way in.

When to reach for it

The moment a controller does body.email.toLowerCase() and you realize nothing guarantees body.email is a string. Other cases:

  • Reject unknown fields so callers can’t slip past business rules by adding isAdmin: true to a registration payload.
  • Coerce ?page=2 (string) to a number for pagination handlers.
  • Run different rules for the same DTO depending on context (create vs. update, public vs. admin).

Setup

npm i class-validator class-transformer

Bind globally in main.ts so every controller is covered:

import "reflect-metadata"
import { NestFactory } from "@nestjs/core"
import { ValidationPipe } from "@nestjs/common"
import { AppModule } from "./app.module"
 
async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
      transformOptions: { enableImplicitConversion: true },
    }),
  )
  await app.listen(3000)
}
bootstrap()

Those four options are the secure default. Each one earns its keep below.

A first DTO

import { IsEmail, IsString, MinLength } from "class-validator"
 
export class CreateUserDto {
  @IsEmail()
  email: string
 
  @IsString()
  @MinLength(8)
  password: string
}
import { Body, Controller, Post } from "@nestjs/common"
import { CreateUserDto } from "./dto/create-user.dto"
 
@Controller("users")
export class UsersController {
  @Post()
  create(@Body() dto: CreateUserDto) {
    return { ok: true, email: dto.email }
  }
}

POST /users with a valid body:

{ "email": "a@b.c", "password": "hunter2!" }
{ "ok": true, "email": "a@b.c" }

Same route with bad input:

{ "email": "not-an-email", "password": "short" }

Returns 400 Bad Request:

{
  "statusCode": 400,
  "message": [
    "email must be an email",
    "password must be longer than or equal to 8 characters"
  ],
  "error": "Bad Request"
}

Nothing else to wire up.

whitelist and forbidNonWhitelisted — the security pair

new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })
  • whitelist: true — silently strips properties that aren’t decorated on the DTO.
  • forbidNonWhitelisted: true — upgrades the silent strip to a 400. Callers learn immediately that the field is unknown.

With whitelist: true only, this request:

{ "email": "a@b.c", "password": "hunter2!", "isAdmin": true }

Arrives at the handler as:

{ "email": "a@b.c", "password": "hunter2!" }

Add forbidNonWhitelisted: true and the same request fails fast:

{
  "statusCode": 400,
  "message": ["property isAdmin should not exist"],
  "error": "Bad Request"
}

Use both in production. Strip-only is fine for migrations where old clients still send deprecated fields you want to ignore.

transform: true — DTOs become real class instances

By default, @Body() dto: CreateUserDto is a plain object that just happens to satisfy the type at compile time. With transform: true, the pipe runs plainToInstance(CreateUserDto, body) so dto instanceof CreateUserDto is true and any methods on the DTO actually work.

import { IsString, MinLength } from "class-validator"
 
export class CreateUserDto {
  @IsString()
  @MinLength(2)
  firstName: string
 
  @IsString()
  @MinLength(2)
  lastName: string
 
  fullName(): string {
    return `${this.firstName} ${this.lastName}`
  }
}

Without transform: true, calling dto.fullName() throws dto.fullName is not a function.

Where the pipe actually instantiates a class

The pipe inspects the metatype of the parameter (the TS type Nest reflects from your handler signature) and skips built-in primitives. Mental check: if the metatype is a custom class with decorators, you get an instance; otherwise the value passes through untouched.

Parameter signatureMetatypeValidation runs?transform produces
@Body() dto: CreateUserDtoCreateUserDtoCreateUserDto instance
@Query() q: PaginationQueryPaginationQueryPaginationQuery instance
@Param() p: GetUserParamsGetUserParamsGetUserParams instance
@Body() raw: objectObjectThe raw POJO from body-parser
@Param('id') id: stringStringThe raw string
@Query('page') page: numberNumberThe raw string ("2", not 2)
@UploadedFile() file: Express.Multer.FileObjectThe raw multer file (validate with ParseFilePipe)

For path/query coercion of a single primitive, reach for [[nestjs/fundamentals/pipes|ParseIntPipe / ParseBoolPipe]] instead — ValidationPipe won’t touch them.

enableImplicitConversion: true

Path/query params arrive as strings. With implicit conversion on, the pipe coerces based on the TS type:

import { IsInt, Max, Min } from "class-validator"
 
export class PaginationQuery {
  @IsInt()
  @Min(1)
  @Max(100)
  limit: number
}

GET /items?limit=10limit is the number 10, not the string "10". Without it, @IsInt() fails because "10" is a string.

Validation groups — same DTO, different rules per route

This is the parallel to class-transformer groups in the serialization recipe. Same pattern, different library: serialization groups pick which fields leave, validation groups pick which rules run.

A real case: on POST /users, password is required. On PATCH /users/:id, the user is updating their profile and shouldn’t have to re-send the password.

import { IsEmail, IsOptional, IsString, MinLength } from "class-validator"
 
export class UserDto {
  @IsEmail({}, { groups: ["create", "update"] })
  email: string
 
  @IsString({ groups: ["create"] })
  @MinLength(8, { groups: ["create"] })
  @IsOptional({ groups: ["update"] })
  password?: string
}

Tell the pipe which group to apply per route:

import { Body, Controller, Patch, Post, UsePipes, ValidationPipe } from "@nestjs/common"
import { UserDto } from "./dto/user.dto"
 
@Controller("users")
export class UsersController {
  @Post()
  @UsePipes(new ValidationPipe({ groups: ["create"], whitelist: true }))
  create(@Body() dto: UserDto) {
    return dto
  }
 
  @Patch(":id")
  @UsePipes(new ValidationPipe({ groups: ["update"], whitelist: true }))
  update(@Body() dto: UserDto) {
    return dto
  }
}

POST /users without a password fails:

{ "email": "a@b.c" }
{
  "statusCode": 400,
  "message": [
    "password must be longer than or equal to 8 characters",
    "password must be a string"
  ],
  "error": "Bad Request"
}

PATCH /users/1 with the same body passes — the update group only requires email:

{ "email": "a@b.c" }
{ "email": "a@b.c" }

Nested objects and arrays

Decorators don’t recurse automatically. You need @ValidateNested() to descend, plus @Type() from class-transformer so the pipe knows which class to instantiate inside arrays.

import { Type } from "class-transformer"
import { IsArray, IsString, MinLength, ValidateNested } from "class-validator"
 
export class OrderItemDto {
  @IsString()
  sku: string
 
  @IsString()
  @MinLength(1)
  quantity: string
}
 
export class CreateOrderDto {
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => OrderItemDto)
  items: OrderItemDto[]
}

Without @Type(), items stay as plain objects and their decorators never run.

Custom validators

When the built-ins aren’t enough, write your own. For a sync rule, a function-style decorator is the lightest:

import { registerDecorator, ValidationOptions } from "class-validator"
 
export function IsSlug(options?: ValidationOptions) {
  return function (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 be lowercase letters, digits, and dashes only`,
      },
    })
  }
}
import { IsSlug } from "./validators/is-slug.validator"
 
export class CreatePostDto {
  @IsSlug()
  slug: string
}

For async rules that need DI (e.g., “is this email already taken?”), use ValidatorConstraint with { async: true } and register the constraint class as a provider — see the class-validator docs.

Customizing the error response

Default error shape is fine for a frontend you control. For a public API, shape it yourself:

import { BadRequestException, ValidationError, ValidationPipe } from "@nestjs/common"
 
new ValidationPipe({
  exceptionFactory: (errors: ValidationError[]) =>
    new BadRequestException({
      statusCode: 400,
      error: "Validation failed",
      details: errors.map((e) => ({
        field: e.property,
        messages: Object.values(e.constraints ?? {}),
      })),
    }),
})

In production, also set disableErrorMessages: true if you don’t want the raw constraint strings reaching the client (and instead return your own copy).

See also