Pipes & Validation
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3, the version the Nest 12 CLI and templates install), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, class-validator 0.15, class-transformer 0.5 and Zod 4, and was checked on 2026-10-02. The examples use a project generated by the Nest 12 CLI (ESM layout, Vitest, supertest) and, for the Fastify differences, the FastifyAdapter with app.inject(); the response bodies shown are the ones the examples produce.
A pipe is a class that receives the value of a handler argument right before the handler runs and either returns it (possibly converted) or throws. That is all, and it is why pipes carry two jobs that most web applications need at their boundary: transformation (the path segment "42" becomes the number 42) and validation (the body is checked against rules, and a bad one never reaches the handler). This page covers the pipe contract, the eleven pipes Nest ships, the four places a pipe can be bound, the class-validator-based ValidationPipe that most Nest code uses, the schema-based StandardSchemaValidationPipe added in Nest 12, how to write your own pipes, and how to choose between the two validation styles. The code is part of the running Bookshelf scenario (books, authors and reviews).
What a pipe is and where it runs
A pipe is an @Injectable() class that implements PipeTransform. Nest calls its transform() method once for every handler parameter the pipe is bound to, and replaces the argument with whatever transform() returns. Pipes run inside the exceptions zone: an exception thrown by a pipe is handled by the exceptions layer exactly like one thrown by the handler, so the client receives a normal HTTP error and the handler is never called (see Exception filters and error handling).
Source: Pipes.
Pipes sit late in the request lifecycle: after middleware, guards and the before part of interceptors, immediately before the handler. That position matters in practice: a guard that rejects the request means no pipe ever runs (so authorization errors are never hidden behind validation errors), and an interceptor’s intercept() code has already executed when a pipe fails (so its catchError operator can observe pipe errors).
before handler] I1 --> P[Pipes
one call per parameter] P --> H[Route handler] H --> I2[Interceptors
after handler] I2 --> R[Response] P -. throws .-> F[Exception filters] G -. throws .-> F H -. throws .-> F F --> R
The complete ordering, including how global, controller and route bindings combine, is on the Request lifecycle page; guards are covered on Guards and interceptors on Interceptors.
Source: Request lifecycle.
Built-in pipes
All eleven built-in pipes are exported from @nestjs/common. The Parse* pipes are the transformation pipes; ValidationPipe and StandardSchemaValidationPipe are the validation pipes; ParseFilePipe and DefaultValuePipe fill the remaining roles.
| Pipe | What it does | Notable options |
|---|---|---|
|
Converts a numeric string to an integer; rejects anything else (including |
|
|
Converts a numeric string to a floating-point number. |
|
|
Converts |
|
|
Splits a string into an array and converts or validates each item; validates a body array of DTOs. |
|
|
Checks that a string is a UUID (any version by default, or a specific one). |
|
|
Checks that a value belongs to an enum and returns it. |
|
|
Converts a date string to a |
|
|
Replaces |
the default value itself |
|
Validates an uploaded file with a list of file validators. |
|
|
Validates a class instance with class-validator rules and optionally converts it with class-transformer. |
every class-validator option plus |
|
Validates a value against a Standard Schema object attached to the parameter (v12). |
|
Source: Pipes, "Built-in pipes".
Binding a Parse pipe
The Parse* pipes are bound on the parameter decorator. Pass the class to let Nest instantiate it (and inject its dependencies), or pass an instance when you need to give it options. Every Parse* pipe works on route parameters, query parameters and body values.
import {
Controller,
DefaultValuePipe,
Get,
HttpStatus,
Param,
ParseArrayPipe,
ParseBoolPipe,
ParseDatePipe,
ParseEnumPipe,
ParseFloatPipe,
ParseIntPipe,
ParseUUIDPipe,
Query,
} from '@nestjs/common';
export enum BookFormat {
Hardcover = 'hardcover',
Paperback = 'paperback',
Ebook = 'ebook',
}
@Controller('parse')
export class ParseController {
// Class reference: Nest instantiates the pipe (DI works).
@Get('int/:id')
int(@Param('id', ParseIntPipe) id: number) {
return { id, type: typeof id };
}
// Instance: lets you pass options.
@Get('int-406/:id')
int406(
@Param(
'id',
new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
)
id: number,
) {
return { id };
}
@Get('float/:price')
float(@Param('price', ParseFloatPipe) price: number) {
return { price };
}
@Get('uuid/:id')
uuid(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return { id };
}
@Get('format/:format')
format(@Param('format', new ParseEnumPipe(BookFormat)) format: BookFormat) {
return { format };
}
@Get('date')
date(@Query('since', new ParseDatePipe({ optional: true })) since?: Date) {
return { since: since?.toISOString() ?? null };
}
@Get('list')
list(
@Query('activeOnly', new DefaultValuePipe(false), ParseBoolPipe)
activeOnly: boolean,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
) {
return { activeOnly, page };
}
@Get('ids')
ids(
@Query('ids', new ParseArrayPipe({ items: Number, separator: ',' }))
ids: number[],
) {
return { ids };
}
}
Source: Pipes, "Binding pipes" and Pipes, "Providing defaults".
Calling these routes shows the outcomes. A pipe error is a 400 Bad Request by default, with a message naming what was expected:
curl -i localhost:3000/parse/int/42
curl -i localhost:3000/parse/int/abc
curl -i localhost:3000/parse/int-406/abc
curl -i localhost:3000/parse/uuid/123
curl -i 'localhost:3000/parse/ids?ids=1,x'
// 200
{ "id": 42, "type": "number" }
// 400
{ "message": "Validation failed (numeric string is expected)", "error": "Bad Request", "statusCode": 400 }
// 406 (errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE)
{ "message": "Validation failed (numeric string is expected)", "error": "Not Acceptable", "statusCode": 406 }
// 400 (uuid with version: '4')
{ "message": "Validation failed (uuid v 4 is expected)", "error": "Bad Request", "statusCode": 400 }
// 400 (ParseArrayPipe, second item)
{ "message": "[1] item must be a number", "error": "Bad Request", "statusCode": 400 }
The remaining outcomes of the same controller: /parse/format/scroll gives Validation failed (enum string is expected), /parse/date?since=yesterday gives Validation failed (invalid date format), /parse/list?activeOnly=yes gives Validation failed (boolean string is expected), and /parse/ids with no query string gives Validation failed (parsable array expected). Each pipe also accepts exceptionFactory to throw a different exception, and errorHttpStatusCode to keep Nest’s message but change the status.
Pitfalls of the Parse pipes
-
A missing value is an error. By default a
Parse*pipe throws when it receivesundefinedornull. Either setoptional: true(the pipe then passesundefinedthrough, asParseDatePipedoes above) or put aDefaultValuePipebefore it in the parameter’s pipe list, as in thelistroute:/parse/listwith no query string produced{ "activeOnly": false, "page": 1 }. Pipes listed on one parameter run left to right. -
ParseIntPipeis strict.1.5is rejected,abcis rejected, and a non-numeric string never becomesNaN. For decimals useParseFloatPipe. -
ParseUUIDPipeaccepts any version unless told otherwise. Withoutversionit accepts versions 1 to 8 and the Nil and Max UUIDs; withversion: '4'a version 1 UUID (6ba7b810-9dad-11d1-80b4-00c04fd430c8) is rejected, as the output above shows. -
There is no
ParseStringPipe. Path and query values are already strings. UseParseIntPipeorParseBoolPipewhen you want an explicit conversion without turning ontransforminValidationPipe. -
ParseDatePipedefault. Thedefaultoption takes a function that returns aDate; since Nest 12.1 it is applied to every missing value (pull request #17827 in the v12.1.0 release notes). Withnew ParseDatePipe({ default: () ⇒ new Date('2026-01-01T00:00:00Z') })a request with nodquery parameter receives2026-01-01T00:00:00.000Z.
Source: Pipes, "Providing defaults" and NestJS v12.1.0 release notes.
ParseArrayPipe
TypeScript emits no metadata for generic types such as CreateBookDto[], so ValidationPipe sees an Array and skips it: the elements of an array body are not validated. Posting [{ "page": "a" }] to a route typed PagingDto[] returns 201. ParseArrayPipe fixes this with its items option, and it also splits a query string into an array with separator.
@Post('bulk')
createBulk(
@Body(new ParseArrayPipe({ items: CreateBookDto, whitelist: true }))
dtos: CreateBookDto[],
) {
return { count: dtos.length };
}
Posting two books of which the second has an invalid isbn produced 400 with { "message": ["isbn must be an ISBN"], … }. The alternative is a wrapper class with one array property decorated with @ValidateNested({ each: true }) and @Type().
ParseFilePipe
ParseFilePipe applies a list of FileValidator instances to an uploaded file. Nest ships MaxFileSizeValidator (size in bytes) and FileTypeValidator (a MIME type string or regular expression, matched against the file’s magic number by default); you can write your own by extending the abstract FileValidator class and implementing isValid() and buildErrorMessage(). ParseFilePipeBuilder composes the same validators without instantiating them yourself. A file is required by default; pass fileIsRequired: false to build() to make it optional.
import type {} from 'multer'; // loads the Express.Multer.File typings
import {
Controller,
FileTypeValidator,
HttpStatus,
MaxFileSizeValidator,
ParseFilePipe,
ParseFilePipeBuilder,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
@Controller('covers')
export class UploadController {
@Post()
@UseInterceptors(FileInterceptor('cover'))
upload(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 1024 }),
new FileTypeValidator({ fileType: 'image/png' }),
],
}),
)
cover: Express.Multer.File,
) {
return { name: cover.originalname, size: cover.size };
}
@Post('builder')
@UseInterceptors(FileInterceptor('cover'))
uploadWithBuilder(
@UploadedFile(
new ParseFilePipeBuilder()
.addMaxSizeValidator({ maxSize: 1024 })
.build({
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
fileIsRequired: false,
}),
)
cover?: Express.Multer.File,
) {
return { uploaded: cover !== undefined };
}
}
The Express.Multer.File type comes from @types/multer. The generated tsconfig.json restricts the types array, so the type-only import type {} from 'multer' line pulls the typings in; without it tsc reports Namespace 'global.Express' has no exported member 'Multer'. The upload routes give these responses:
// POST /covers with no file: 400
{ "message": "File is required", "error": "Bad Request", "statusCode": 400 }
// POST /covers with a text/plain file: 400
{ "message": "Validation failed (current file type is text/plain, expected type is image/png)", "error": "Bad Request", "statusCode": 400 }
// POST /covers with a 2048-byte file: 400
{ "message": "Validation failed (current file size is 2048, expected size is less than 1024)", "error": "Bad Request", "statusCode": 400 }
// POST /covers/builder with a 2048-byte file: 422 (errorHttpStatusCode)
{ "message": "Validation failed (current file size is 2048, expected size is less than 1024)", "error": "Unprocessable Entity", "statusCode": 422 }
// POST /covers/builder with no file: 201 (fileIsRequired: false)
{ "uploaded": false }
With the Fastify adapter, Nest 12.1 serves uploads with @fastify/multipart and the interceptors come from @nestjs/platform-fastify/multipart; ParseFilePipe, ParseFilePipeBuilder and the validators work unchanged, and the file type becomes UploadedMultipartFile. Uploads themselves are covered with the HTTP features.
Source: File upload and streaming, "Fastify".
Binding scopes
A pipe can be bound at four levels. All four accept a class (instantiated and injected by Nest) or an instance (created by you).
| Level | How | Applies to |
|---|---|---|
Parameter |
|
That one parameter |
Method |
|
Every parameter of that handler |
Controller |
|
Every parameter of every handler in the controller |
Global |
|
Every parameter of every handler in the application |
For one parameter the pipes run in the order global, controller, method, parameter; pipes passed to a single @UsePipes() run first in, first out; and when a handler has several parameters with pipes, they are processed from the last parameter to the first. The example below uses one tiny pipe class per level so that the order is visible in the result.
import {
ArgumentMetadata,
Controller,
Get,
Injectable,
Module,
PipeTransform,
Query,
UsePipes,
} from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
// One pipe class per binding level, each appending its own label to the value.
const makePipe = (label: string) => {
@Injectable()
class LabelPipe implements PipeTransform<string, string> {
transform(value: string, _metadata: ArgumentMetadata): string {
return `${value}>${label}`;
}
}
return LabelPipe;
};
export const GlobalLabel = makePipe('global');
export const ControllerLabel = makePipe('controller');
export const MethodLabel = makePipe('method');
export const ParamLabel = makePipe('param');
@Controller('scopes')
@UsePipes(ControllerLabel)
export class ScopesController {
@Get()
@UsePipes(MethodLabel)
read(@Query('q', ParamLabel) q: string) {
return { q };
}
}
@Module({
controllers: [ScopesController],
providers: [{ provide: APP_PIPE, useClass: GlobalLabel }],
})
export class ScopesModule {}
Source: Request lifecycle, "Pipes".
GET /scopes?q=x returns { "q": "x>global>controller>method>param" }.
Global pipes: useGlobalPipes() versus APP_PIPE
A global pipe registered with app.useGlobalPipes() is created outside any module, so it cannot inject dependencies. To give a global pipe dependencies, register it as a provider with the APP_PIPE token. The pipe is then global regardless of the module that declares the provider, so register it in the module that owns the pipe.
import { Module, ValidationPipe } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_PIPE,
useFactory: () => new ValidationPipe({ whitelist: true, transform: true }),
},
],
})
export class AppModule {}
Source: Pipes, "Global scoped pipes".
The useFactory form above, with transform and implicit conversion enabled, behaves as a global pipe. A StampPipe that injects a Clock service and is registered with { provide: APP_PIPE, useClass: StampPipe } receives the injected service: GET /extras/stamp?q=hi returns { "q": "hi@2026-10-02" }. The same global pipe also matters for testing: a global pipe registered with useGlobalPipes() is part of main.ts, not of AppModule, so an end-to-end test built with Test.createTestingModule() does not get it unless the test calls app.useGlobalPipes() too, whereas an APP_PIPE provider is always part of the module graph. See Providers and dependency injection for the provider forms.
Two further scope rules:
-
Hybrid applications. In a hybrid application (an HTTP application that also connects a microservice),
useGlobalPipes()does not set up pipes for the connected microservices by default; in a standard microservice application it does mount them globally. -
Other transports. Pipes work the same on WebSocket gateways and microservice handlers. By default they throw HTTP exceptions, so for those transports return a
WsExceptionorRpcExceptionfrom the pipe’sexceptionFactoryoption.
Custom pipes
PipeTransform and ArgumentMetadata
Every pipe implements PipeTransform<T, R>, where T is the type of the incoming value and R the type transform() returns. The method receives the value and an ArgumentMetadata object describing the parameter being processed:
export interface ArgumentMetadata<Metatype = any> {
type: 'body' | 'query' | 'param' | 'custom';
metatype?: Type<Metatype> | undefined;
data?: string | undefined;
schema?: StandardSchemaV1;
}
| Property | Meaning |
|---|---|
|
Whether the value comes from |
|
The runtime class of the parameter as emitted by the compiler (for example |
|
The string given to the decorator, for example |
|
A Standard Schema object attached through the |
Source: Pipes, "Custom pipes".
Nest 12 made ArgumentMetadata generic (ArgumentMetadata<Metatype>) and refined the pipe transform signatures for stronger type safety, so a pipe with a hand-written transform() signature may need its types adjusted when you upgrade from Nest 11. Code that only writes ArgumentMetadata without a type argument keeps compiling, because the parameter has a default.
A transformation pipe
The value returned by transform() replaces the argument. A pipe that throws stops the request before the handler is called. Pipes may be async: Nest awaits the returned promise.
import {
ArgumentMetadata,
BadRequestException,
Injectable,
NotFoundException,
PipeTransform,
} from '@nestjs/common';
import { Book, BooksRepository } from './books.repository.js';
// Transformation: trims every string it receives. Generic over input/output.
@Injectable()
export class TrimPipe implements PipeTransform<string, string> {
transform(value: string, metadata: ArgumentMetadata): string {
if (typeof value !== 'string') {
throw new BadRequestException(
`${metadata.type} "${metadata.data ?? ''}" must be a string`,
);
}
return value.trim();
}
}
// Transformation with DI: resolves an id into an entity.
@Injectable()
export class BookByIdPipe implements PipeTransform<string, Book> {
constructor(private readonly books: BooksRepository) {}
transform(value: string): Book {
const id = Number.parseInt(value, 10);
if (Number.isNaN(id)) {
throw new BadRequestException('id must be an integer');
}
const book = this.books.findOne(id);
if (!book) {
throw new NotFoundException(`Book ${id} not found`);
}
return book;
}
}
// Reading ArgumentMetadata: report what Nest tells the pipe about the parameter.
@Injectable()
export class DescribeMetadataPipe implements PipeTransform {
transform(value: unknown, metadata: ArgumentMetadata) {
return {
value,
type: metadata.type,
data: metadata.data ?? null,
metatype: metadata.metatype?.name ?? null,
};
}
}
Source: Pipes, "Transformation use case".
Bound to parameters, the pipes behave as follows:
@Get('by-id/:id')
byId(@Param('id', BookByIdPipe) book: Book) {
return book;
}
@Get('slug/:slug')
slug(@Param('slug', TrimPipe) slug: string) {
return { slug };
}
@Get('meta')
meta(@Query('q', DescribeMetadataPipe) q: unknown) {
return q;
}
curl localhost:3000/books/by-id/1
curl localhost:3000/books/by-id/9
curl 'localhost:3000/books/slug/%20dune%20'
curl 'localhost:3000/tools/meta?q=hi'
{ "id": 1, "title": "Dune", "isbn": "9780441172719" }
{ "message": "Book 9 not found", "error": "Not Found", "statusCode": 404 }
{ "slug": "dune" }
{ "value": "hi", "type": "query", "data": "q", "metatype": "Object" }
BookByIdPipe receives its repository through the constructor, because it is a provider in the module and is bound by class. The last response shows the interface pitfall in action: q was declared unknown, so metatype is Object. A pipe that decides what to do from metatype (as ValidationPipe does) therefore cannot work with parameters typed by interfaces or generics.
A pipe that throws any HttpException (here a 404) controls the status the client sees; throwing a plain Error produces a 500.
A validation pipe written by hand
Building a small validation pipe shows the mechanics the built-in ones rely on. This one takes a Zod schema in its constructor, parses the incoming value and throws a 400 with the issues; on success it returns the parsed value, so defaults and coercions take effect.
import {
ArgumentMetadata,
BadRequestException,
PipeTransform,
} from '@nestjs/common';
import type { ZodType } from 'zod';
// A hand-written, schema-based pipe: the pattern the built-in
// StandardSchemaValidationPipe generalises.
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodType) {}
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: 'Validation failed',
issues: result.error.issues.map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
});
}
return result.data;
}
}
Source: Pipes, "Object schema validation".
Bound with @UsePipes(new ZodValidationPipe(createReviewSchema)) on a handler, a body of { "bookId": "x", "rating": 9 } produced:
{
"message": "Validation failed",
"issues": [
{ "path": "bookId", "message": "Invalid input: expected number, received NaN" },
{ "path": "rating", "message": "Too big: expected number to be <=5" }
]
}
Zod requires the strictNullChecks compiler option, which the generated tsconfig.json already enables through strict: true. For production use the built-in StandardSchemaValidationPipe (below) instead of maintaining this class: it supports Zod, Valibot, ArkType and any other Standard Schema library, and it reads the schema from the parameter decorator so one global pipe serves every route.
ValidationPipe with class-validator and class-transformer
ValidationPipe is the pipe most Nest applications bind globally. It reads the rules from decorators on a DTO class (class-validator), turns the plain request payload into an instance of that class (class-transformer), validates it, and either throws a 400 listing the violations or hands the handler the result. Install the two libraries it uses:
npm i class-validator class-transformer
Because the DTO class is also the parameter’s TypeScript type, the compiler’s emitted metadata gives the pipe the metatype it needs; this is the reason for emitDecoratorMetadata (see TypeScript for NestJS).
How it processes a value
with a real runtime type?} T -- "no (String, Number, Object, undefined)" --> S{transform enabled?} T -- yes --> TI[class-transformer
plainToInstance] TI --> VA[class-validator
validate] VA --> E{errors?} E -- yes --> X[exceptionFactory
default: BadRequestException] E -- no --> O{transform enabled?} O -- yes --> RI[Return the class instance] O -- no --> RP[Return the original plain value
or the stripped copy if whitelist is set] S -- yes --> PR[Convert primitive types
such as the id parameter to number] S -- no --> PS[Return the value unchanged]
The two end states differ in a way that surprises people: without transform: true the handler receives the original plain object (the validation ran on a converted copy), so dto instanceof CreateBookDto is false and string query values stay strings. With transform: true the handler receives the class instance. A handler that reports dto instanceof CreateBookDto returns "isInstance": false for the default pipe and "isInstance": true with transform: true.
The DTOs
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsInt,
IsISBN,
IsOptional,
IsString,
Length,
Max,
Min,
ValidateNested,
} from 'class-validator';
export class AuthorDto {
@IsString()
@Length(1, 80)
name!: string;
@IsOptional()
@IsInt()
@Min(1000)
@Max(2100)
birthYear?: number;
}
export class CreateBookDto {
@IsString()
@Length(1, 120)
title!: string;
@IsISBN()
isbn!: string;
@IsInt()
@Min(1)
pages!: number;
@ValidateNested({ each: true })
@Type(() => AuthorDto)
@ArrayMinSize(1)
authors!: AuthorDto[];
}
Source: Validation, "Auto-validation". The decorator catalogue is in the class-validator documentation.
Three details of this DTO deserve explanation:
-
name!: stringuses the definite-assignment assertion. The generatedtsconfig.jsonsetsstrictPropertyInitializationtofalse, so the!is optional there, but it keeps the DTO valid in a project that enables the flag. -
A nested array needs both
@ValidateNested({ each: true })(validate each element) and@Type(() ⇒ AuthorDto)(class-transformer must know which class to instantiate, because the element type is erased at runtime). Without@Type()the nested rules are silently skipped: a body with"items": [{ "name": 5 }]for a property that has only@ValidateNested({ each: true })is accepted with201. -
DTOs must be classes imported with a normal import. A type-only import (
import type { CreateBookDto }) is erased, the emitted metadata then namesObject, and nothing is validated. The same is true for DTOs declared as interfaces or generics.
Binding it globally
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
Source: Validation, "Auto-validation".
With this in place and the handler create(@Body() dto: CreateBookDto), an invalid body is answered with every violation, nested paths included:
curl -i -X POST localhost:3000/books -H 'content-type: application/json' \
-d '{"title":"","isbn":"x","pages":"a","authors":[{"name":5,"birthYear":5}]}'
{
"message": [
"title must be longer than or equal to 1 characters",
"isbn must be an ISBN",
"pages must not be less than 1",
"pages must be an integer number",
"authors.0.name must be longer than or equal to 1 and shorter than or equal to 80 characters",
"authors.0.name must be a string",
"authors.0.birthYear must not be less than 1000"
],
"error": "Bad Request",
"statusCode": 400
}
An empty authors array yields "authors must contain at least 1 elements". A valid book returns 201.
Every option
ValidationPipeOptions extends class-validator’s ValidatorOptions, so one object configures both libraries. The pipe’s own options are transform, disableErrorMessages, errorHttpStatusCode, exceptionFactory, errorFormat, transformOptions (passed to class-transformer), validateCustomDecorators, expectedType, validatorPackage and transformerPackage. The class-validator options include:
| Option | Type | Effect |
|---|---|---|
|
|
Strips properties that have no validation decorator from the validated object. |
|
|
With |
|
|
Makes validation of unknown objects fail immediately. |
|
|
Returns a DTO instance and converts primitive parameters to their declared type. |
|
|
Options for class-transformer, for example |
|
|
Hides the violation details from the client. |
|
|
Status of the thrown exception; |
|
|
Builds the exception to throw. |
|
|
Shape of the error message: an array of strings (default) or an object keyed by property path (Nest 12). |
|
|
Stops validating a property after its first failing rule. |
|
|
Skips validation of properties that are |
|
|
Validation groups and the defaults for the |
|
|
Leaves messages |
|
|
Whether the |
|
|
Prints extra warnings to the console. |
whitelist and forbidNonWhitelisted
Without options the pipe keeps unknown properties: posting { …, "admin": true } returned 201 with "admin": true still in the object, which is how mass-assignment bugs start. With whitelist: true properties that have no validation decorator are stripped, nested objects included; with forbidNonWhitelisted: true as well they are rejected.
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
// or, rejecting instead of stripping:
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }),
);
Source: Validation, "Stripping properties".
// whitelist: true, body { ..., "admin": true, "authors": [{ "name": "F", "evil": 1 }] }
{ "created": { "title": "Dune", "isbn": "9780441172719", "pages": 412, "authors": [{ "name": "F" }] }, "isInstance": false }
// whitelist + forbidNonWhitelisted, body { ..., "admin": true }
{ "message": ["property admin should not exist"], "error": "Bad Request", "statusCode": 400 }
A property is only whitelisted if it carries at least one class-validator decorator. A field you want to accept but need not validate must still be decorated (@IsOptional() or @Allow()), otherwise whitelist strips it.
transform, query DTOs and implicit conversion
Path and query parameters always arrive as strings. With transform: true the pipe converts primitives to the declared type: @Param('id') id: number receives a number (GET /parse/int/7 with the transforming pipe returned { "id": 7, "type": "number" }). For a query DTO class, tell class-transformer how to convert each property with @Type(), or let it convert by the declared type with enableImplicitConversion:
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class PageQueryDto {
// Without @Type(), query strings stay strings unless implicit conversion is on.
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number;
}
app.useGlobalPipes(new ValidationPipe({ transform: true }));
// Alternative to per-property @Type(): convert by declared type everywhere.
app.useGlobalPipes(
new ValidationPipe({
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
Source: Validation, "Transform payload objects" and Configuration, "Custom validate function" (for enableImplicitConversion).
With GET /books?page=2&limit=5 the handler received page as the number 2 with the transforming pipe, and as the string "2" with the default pipe; GET /books?page=zero returned 400 with page must not be less than 1 and page must be an integer number. A DTO with no @Type() and enableImplicitConversion: true converted page=3 to the number 3 and rejected page=abc with page must be an integer number.
Implicit conversion has a trade-off: it converts by the TypeScript type emitted by the compiler, so a boolean property receiving the string "false" is converted with JavaScript truthiness rules (a non-empty string is true); ?active=false produces active: true. Prefer explicit @Transform() or ParseBoolPipe for booleans.
errorFormat, disableErrorMessages, errorHttpStatusCode, stopAtFirstError
By default message is an array of strings. errorFormat: 'grouped' (new in Nest 12) returns an object keyed by property path, with custom messages preserved and no parent-path prefixes. This changes message from string[] to an object, so exception filters or interceptors that assume an array must be updated.
app.useGlobalPipes(new ValidationPipe({ errorFormat: 'grouped' }));
{
"message": {
"title": ["title must be longer than or equal to 1 characters"],
"isbn": ["isbn must be an ISBN"],
"pages": ["pages must not be less than 1", "pages must be an integer number"],
"authors.0.name": [
"name must be longer than or equal to 1 and shorter than or equal to 80 characters",
"name must be a string"
]
},
"error": "Bad Request",
"statusCode": 400
}
Source: Validation, "Using the built-in ValidationPipe" (option table) and Migration guide.
// Hide details in production.
app.useGlobalPipes(new ValidationPipe({ disableErrorMessages: true }));
// 422 instead of 400, and one message per property.
app.useGlobalPipes(
new ValidationPipe({ errorHttpStatusCode: 422, stopAtFirstError: true }),
);
With disableErrorMessages: true the response is { "message": "Bad Request", "statusCode": 400 }. With stopAtFirstError: true validation stops after the first failing rule of each property, so the response still has one message per invalid property (not one message overall).
exceptionFactory
exceptionFactory receives the array of class-validator ValidationError objects and returns the exception to throw. Use it to produce your own error contract, such as a 422 with a structured list (a problem-details body is a natural fit; see Exception filters and error handling).
import {
UnprocessableEntityException,
ValidationPipe,
} from '@nestjs/common';
import type { ValidationError } from 'class-validator';
export const unprocessable = new ValidationPipe({
exceptionFactory: (errors: ValidationError[]) =>
new UnprocessableEntityException({
message: 'Validation failed',
fields: errors.map((e) => ({
field: e.property,
problems: Object.values(e.constraints ?? {}),
})),
}),
});
Posting { "title": "" } produced 422 with { "message": "Validation failed", "fields": [ { "field": "title", "problems": ["title must be longer than or equal to 1 characters"] }, { "field": "isbn", … }, … ] }. Note that e.constraints holds only the direct violations of a property; errors of nested objects are in e.children, which a production factory should walk.
For WebSocket and microservice handlers return a WsException or an RpcException here.
Validation groups
class-validator decorators accept groups, and the pipe’s groups option selects which ones apply. This lets one DTO serve a create and an update route.
export class AccountDto {
@IsString({ groups: ['create'] })
@IsOptional({ groups: ['update'] })
name?: string;
}
@Post('create-group')
@UsePipes(new ValidationPipe({ groups: ['create'] }))
createGroup(@Body() dto: AccountDto) { return dto; }
@Post('update-group')
@UsePipes(new ValidationPipe({ groups: ['update'] }))
updateGroup(@Body() dto: AccountDto) { return dto; }
Source: Validation, "Using the built-in ValidationPipe" (the groups, always and strictGroups options).
An empty body was rejected by the first route (name must be a string) and accepted by the second ({}). Mapped types (below) usually express the create/update split more clearly than groups do.
Custom validators, including ones that use dependency injection
A custom rule is a @ValidatorConstraint() class plus a decorator that registers it with registerDecorator(). The constraint can be asynchronous (return a promise), which is how database-backed rules are written. For the constraint to receive dependencies from Nest, mark it @Injectable(), add it to a module’s providers, and tell class-validator to resolve constraints through Nest’s container with useContainer() before the application starts.
import { Injectable } from '@nestjs/common';
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { BooksRepository } from './books.repository.js';
@ValidatorConstraint({ name: 'isbnAvailable', async: true })
@Injectable()
export class IsbnAvailableConstraint implements ValidatorConstraintInterface {
constructor(private readonly books: BooksRepository) {}
async validate(isbn: string): Promise<boolean> {
return !(await this.books.existsByIsbn(isbn));
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} ${args.value} is already registered`;
}
}
export function IsbnAvailable(options?: ValidationOptions): PropertyDecorator {
return (object: object, propertyName: string | symbol) => {
registerDecorator({
target: object.constructor,
propertyName: propertyName as string,
options,
constraints: [],
validator: IsbnAvailableConstraint,
});
};
}
import { IsString } from 'class-validator';
import { useContainer } from 'class-validator';
export class RegisterIsbnDto {
@IsString()
@IsbnAvailable({ message: 'isbn is already taken' })
isbn!: string;
}
// main.ts, before app.listen():
useContainer(app.select(AppModule), { fallbackOnErrors: true });
Source: class-validator, "Custom validation classes". Nest’s documentation points to the class-validator repository for custom validators.
fallbackOnErrors: true lets class-validator construct constraints that Nest does not know about with its own container instead of failing. Without useContainer() an injectable constraint is instantiated by class-validator with no arguments, and this.books is undefined at runtime. Posting an isbn that already exists returns 400 with ["isbn is already taken"], and a free one returns 201. Two details: the async validator runs during the pipe, so a slow lookup delays the request, and a uniqueness check in a validator does not replace a database unique constraint (two concurrent requests can both pass it).
Mapped types
Create and update variants of a DTO should not repeat the decorators. @nestjs/mapped-types provides PartialType(), PickType(), OmitType() and IntersectionType(), which build a new class that inherits both the properties and their validation decorators. Compose them freely.
import {
IntersectionType,
OmitType,
PartialType,
PickType,
} from '@nestjs/mapped-types';
// All properties optional, validation rules kept.
export class UpdateBookDto extends PartialType(CreateBookDto) {}
// Only isbn.
export class BookIdentityDto extends PickType(CreateBookDto, ['isbn'] as const) {}
// Everything except authors.
export class BookWithoutAuthorsDto extends OmitType(CreateBookDto, [
'authors',
] as const) {}
export class AuditInfo {
@IsString()
requestedBy!: string;
}
// Union of the properties of two classes.
export class CreateBookAuditedDto extends IntersectionType(
CreateBookDto,
AuditInfo,
) {}
Source: Validation, "Mapped types".
PATCH /books/3 with { "title": "Dune Messiah" } returned { "id": 3, "changes": { "title": "Dune Messiah" } }, and { "pages": 0 } returned 400 with pages must not be less than 1: the rules survive PartialType(). POST /books/audited without requestedBy returned requestedBy must be a string.
Import these utilities from @nestjs/mapped-types only when you use neither @nestjs/swagger nor @nestjs/graphql. Those packages export their own versions of the same functions, because they also copy their own metadata (@ApiProperty(), @Field()); importing the @nestjs/mapped-types versions in such a project can cause undocumented side effects. The OpenAPI and GraphQL variants are covered with those features.
Source: OpenAPI, "Mapped Types" and GraphQL, "Mapped types".
validateCustomDecorators
ValidationPipe skips parameters from custom decorators created with createParamDecorator() unless you set validateCustomDecorators: true. See Custom decorators for how to write the decorator; the schema-based pipe has the same option and an example below.
Standard Schema validation
Nest 12 added schema-first validation: attach a schema to a parameter and bind StandardSchemaValidationPipe. It accepts any schema that implements the Standard Schema specification, which includes Zod, Valibot and ArkType. The schema is the single source of truth: it validates at runtime, and its inferred type is the parameter’s type. No class, no decorators and no emitted metadata are involved.
The schema option
@Body(), @Query(), @Param() and @RawBody() accept a schema option, as do @MessageBody() in gateways and @Payload() in microservices and any custom decorator made with createParamDecorator(). On its own the option only attaches metadata (it shows up as ArgumentMetadata.schema); the validation is done by the pipe.
import { z } from 'zod';
export const createReviewSchema = z.object({
bookId: z.coerce.number().int().positive(),
rating: z.number().int().min(1).max(5),
comment: z.string().trim().max(500).optional(),
});
export type CreateReviewDto = z.infer<typeof createReviewSchema>;
export const listReviewsQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().trim().optional(),
});
export type ListReviewsQuery = z.infer<typeof listReviewsQuerySchema>;
export const updateReviewSchema = createReviewSchema.partial();
export const strictReviewSchema = z.strictObject({
rating: z.number().int().min(1).max(5),
});
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { z } from 'zod';
import {
createReviewSchema,
listReviewsQuerySchema,
strictReviewSchema,
updateReviewSchema,
type CreateReviewDto,
type ListReviewsQuery,
} from './schemas.js';
@Controller('reviews')
export class ReviewsController {
@Post()
create(@Body({ schema: createReviewSchema }) dto: CreateReviewDto) {
return dto;
}
@Post('strict')
strict(@Body({ schema: strictReviewSchema }) dto: { rating: number }) {
return dto;
}
@Post('partial')
partial(@Body({ schema: updateReviewSchema }) dto: Partial<CreateReviewDto>) {
return dto;
}
@Post('bulk')
bulk(@Body({ schema: z.array(createReviewSchema) }) dtos: CreateReviewDto[]) {
return { count: dtos.length };
}
@Post('email')
email(@Body('email', { schema: z.email() }) email: string) {
return { email };
}
@Get()
list(@Query({ schema: listReviewsQuerySchema }) query: ListReviewsQuery) {
return query;
}
@Get(':id')
findOne(
@Param('id', { schema: z.coerce.number().int().positive() }) id: number,
) {
return { id, type: typeof id };
}
}
Pass the schema alone to validate the whole object, or after a property name (@Body('email', { schema })) to validate one value. Bind the pipe once, globally:
import { StandardSchemaValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new StandardSchemaValidationPipe());
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
The pipe validates only parameters that declare a schema and passes everything else through unchanged, so binding it globally is safe: POST /books with {} (no schema, a class DTO, no ValidationPipe) passes straight through. Messages are prefixed with the path of the invalid property:
// POST /reviews { "bookId": "x", "rating": 9 }
{
"message": [
"bookId: Invalid input: expected number, received NaN",
"rating: Too big: expected number to be <=5"
],
"error": "Bad Request",
"statusCode": 400
}
// POST /reviews/bulk, second element invalid: the message is prefixed by its index
{ "message": ["1.rating: Too small: expected number to be >=1"], "error": "Bad Request", "statusCode": 400 }
// GET /reviews/-1
{ "message": ["Too small: expected number to be >0"], "error": "Bad Request", "statusCode": 400 }
Coercion, defaults and the output value
Query and path values are strings, so coercing schemas (z.coerce.number()) convert them before validating. By default the pipe passes the schema’s output to the handler, so coercions, defaults and transformations take effect: POST /reviews with { "bookId": "3", "rating": 5, "comment": " great ", "extra": 1 } returned { "bookId": 3, "rating": 5, "comment": "great" } (coerced, trimmed, and the unknown extra stripped by z.object()), and GET /reviews?page=2&search=%20dune%20 returned { "page": 2, "limit": 20, "search": "dune" }. With transform: false the handler receives the original input after successful validation ({ "page": "2" } for the query above), and the parameter should then be typed with the schema’s input type (z.input<typeof schema> in Zod).
Unknown properties
How unknown properties are treated is the schema’s decision, not the pipe’s. In Zod z.object() strips them (like whitelist), z.strictObject() rejects them (like forbidNonWhitelisted) and z.looseObject() keeps them. Posting { "rating": 3, "age": 1 } to the strict route returned Unrecognized key: "age".
Deriving schemas
The mapped-types utilities have schema-library counterparts:
export const updateUserSchema = createUserSchema.partial(); // PartialType()
export const changeEmailSchema = createUserSchema.pick({ email: true }); // PickType()
export const publicProfileSchema = createUserSchema.omit({ password: true }); // OmitType()
export const createAdminSchema = createUserSchema.extend({ // IntersectionType()
role: z.enum(['admin', 'editor']),
});
Source: Validation, "Deriving schemas".
Arrays need nothing special: z.array(schema) validates every element (no ParseArrayPipe needed).
Valibot and ArkType
Nothing in the pipe is specific to Zod. The same schema option accepts a Valibot schema and an ArkType schema:
import * as v from 'valibot';
import { type } from 'arktype';
const valibotSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
const arkSchema = type({ email: 'string.email', password: 'string >= 8' });
@Post('valibot')
valibot(@Body({ schema: valibotSchema }) dto: v.InferOutput<typeof valibotSchema>) {
return dto;
}
@Post('arktype')
arktype(@Body({ schema: arkSchema }) dto: typeof arkSchema.infer) {
return dto;
}
Source: Validation, "Other schema libraries".
Error text comes from the library, so wording differs:
// Valibot
{ "message": ["email: Invalid email: Received \"x\"", "password: Invalid length: Expected >=8 but received 5"], "error": "Bad Request", "statusCode": 400 }
// ArkType
{ "message": ["email: email must be an email address (was \"x\")", "password: password must be at least length 8 (was 5)"], "error": "Bad Request", "statusCode": 400 }
Options
| Option | Type | Effect |
|---|---|---|
|
|
|
|
|
Also validate parameters of |
|
|
Forwarded to the schema’s |
|
|
Status of the thrown exception (default |
|
|
Receives the Standard Schema issues and returns the exception to throw. |
app.useGlobalPipes(
new StandardSchemaValidationPipe({
exceptionFactory: (issues) =>
new UnprocessableEntityException({
message: 'Validation failed',
errors: issues.map((issue) => ({
path: issue.path
?.map((segment) => (typeof segment === 'object' ? segment.key : segment))
.join('.'),
message: issue.message,
})),
}),
}),
);
errorHttpStatusCode: 422 alone changed the status of the review errors to 422 Unprocessable Entity while keeping the message list.
Custom decorators
A parameter created with createParamDecorator() accepts the schema option too, but the pipe skips custom decorators unless validateCustomDecorators: true. @TenantId({ schema: z.uuid() }) with the header x-tenant-id: nope returns 200 with the default pipe and 400 (Invalid UUID) once the option is set:
export const TenantId = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): string =>
ctx.switchToHttp().getRequest().headers['x-tenant-id'],
);
@Get('tenant-schema')
tenantSchema(@TenantId({ schema: z.uuid() }) tenant: string) {
return { tenant };
}
app.useGlobalPipes(
new StandardSchemaValidationPipe({ validateCustomDecorators: true }),
);
Using both pipes together
The two pipes can be registered at the same time. ValidationPipe only acts on parameters typed with a class that has rules, and StandardSchemaValidationPipe only on parameters that declare a schema, so a codebase can adopt schemas gradually. Registering them together in one useGlobalPipes() call, a class-DTO route and a schema route each report their own errors.
app.useGlobalPipes(
new ValidationPipe({ transform: true }),
new StandardSchemaValidationPipe(),
);
Source: Validation, "Overview".
class-validator or Standard Schema?
| Aspect | ValidationPipe (class-validator) |
StandardSchemaValidationPipe |
|---|---|---|
Where the rules live |
Decorators on a DTO class |
A schema object (Zod, Valibot, ArkType, …) |
Parameter type |
The DTO class itself (needs |
Inferred from the schema ( |
Runtime reflection |
Required: the pipe reads the emitted |
None |
Output |
Plain object, or a class instance with |
The schema’s output (coerced, defaulted, transformed) with |
Unknown properties |
|
Decided by the schema ( |
Nested objects and arrays |
|
Composed in the schema; arrays are ordinary schemas |
Derived variants |
|
|
Async rules and DI |
Async constraints with Nest DI through |
Async refinements in the schema; DI is not available inside the schema |
OpenAPI documentation |
|
Standard Schema support in |
Error format |
|
|
Maturity in Nest |
Since the first versions; most examples and libraries assume it |
New in Nest 12; the decorator approach stays supported |
A reasonable default for a new project: pick one style per application and stick to it. If your team already writes Zod or Valibot schemas for clients or configuration, or you want types inferred without reflection, use the schema pipe. If your project leans on decorators (the Swagger CLI plugin, mapped types, class-based DTOs shared with TypeORM entities), ValidationPipe fits better. Whichever you choose, validate at the boundary (the global pipe) and keep business rules that depend on stored data in the service layer.
Express and Fastify
Pipes are platform-agnostic: transform() receives the same values on both adapters, and everything above produces identical responses on Express 5 and Fastify 5 (/parse/int/abc, /books?page=2 with transform: true, and the review schema errors). What differs is the input that reaches the pipe:
-
Query string parsing. Express 5 defaults to the
simplequery parser, which does not understand brackets:?filter[title]=xarrives as the key"filter[title]", not as a nested object. Fastify’s default behaves the same way. Repeated keys (?tag=a&tag=b) become arrays on both. To validate nested query objects with a DTO, switch the parser (app.set('query parser', 'extended')in Express; thequerystringParseroption ofFastifyAdapter); the controllers chapter covers this. -
Arrays from comma-separated strings.
ParseArrayPipewithseparator: ','handled?ids=1,2,3on both, and Fastify’s repeated-key form?ids=1&ids=2also returned[1, 2]. -
Files.
ParseFilePipeworks on both, but the interceptors and the file type differ (Multer andExpress.Multer.Fileversus@fastify/multipartandUploadedMultipartFile), and@fastify/multipartapplies stricter default limits than Multer.
Source: Controllers, "Query parameters" and File upload and streaming, "Fastify". See also Controllers and routing.
Pitfalls checklist
| Symptom | Cause and fix |
|---|---|
A DTO is never validated |
The parameter is typed with an interface, a type alias or a generic, or the DTO was imported with |
Array body elements are not validated |
Arrays of DTOs lose their element type. Use |
Nested rules are ignored |
Missing |
|
|
Query numbers are strings |
Query values are strings. Enable |
Unknown fields reach the service |
Set |
An injectable constraint has undefined dependencies |
|
A global pipe cannot inject a service |
Registered with |
The pipe never runs in a test |
The test module did not call |
A |
Add |
The method-level pipe seems ignored |
A global pipe ran first and already threw; with both bound, the global one reports first. Global, controller, method and parameter pipes all run. |
|
|
Zod types become |
|
A pipe throws a 500 |
It threw a plain |
An example of the "method-level pipe ignored" row: POST /books/registrations carries @UsePipes(new ValidationPipe({ errorFormat: 'grouped' })), yet with a global ValidationPipe bound the response is the list format produced by the global pipe, which validates first and throws.
Where the book is outdated
The reference book (see the section bibliography) predates Nest 12. When you follow it on this topic, note that:
-
A pipe is an
@Injectable()class implementingPipeTransform; there is no@Pipe({ name })decorator (an Angular concept) in Nest. -
Standard Schema validation (
StandardSchemaValidationPipeand theschemaoption on parameter decorators),ValidationPipe’s `errorFormatand the genericArgumentMetadataare newer than the book. -
Validation failures are
4xxresponses thrown by the pipe, never200responses carrying an error object. -
Validate request input with a pipe, not in the handler body, and never trust the TypeScript type alone: types do not exist at runtime.
What changed recently
| Version | Change |
|---|---|
Nest 12.0 |
Standard Schema validation: the |
Nest 12.1 |
|
|
|
Source: Migration guide and NestJS v12.1.0 release notes. The full upgrade checklist is on What’s changed and migration.