Pipes Overview
Pipes are classes that run just before your route handler executes, giving you a single, declarative place to transform or validate incoming arguments. They sit between the framework’s argument resolution and your controller method, so by the time your handler runs the data is already coerced into the right shape — or the request has been rejected with a clean error. This keeps controllers thin and validation logic reusable across the entire application.
What a pipe does
Every NestJS pipe operates on the arguments being passed to a route handler — the values produced by parameter decorators like @Body(), @Param(), and @Query(). A pipe receives that value, does its work, and returns a (possibly modified) value that Nest forwards to the handler. Pipes have exactly two responsibilities:
- Transformation — convert input data into the desired form (for example, a string
"42"from a route param into the number42). - Validation — evaluate input and, if it is invalid, throw an exception so the handler never runs.
Because pipes run inside the request lifecycle, a thrown exception is caught by the global exceptions layer and turned into a structured HTTP response automatically.
The PipeTransform interface
A pipe is any @Injectable() class that implements the PipeTransform<T, R> interface. That interface defines a single method, transform, which Nest invokes for each argument the pipe is bound to.
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
@Injectable()
export class TrimPipe implements PipeTransform<string, string> {
transform(value: string, metadata: ArgumentMetadata): string {
if (typeof value !== 'string') {
return value;
}
return value.trim();
}
}
The transform method takes two parameters:
| Parameter | Type | Description |
|---|---|---|
value | T | The currently processed argument before it reaches the handler. |
metadata | ArgumentMetadata | Information about the argument: its type, metatype, and parameter data. |
The metadata object is what makes generic pipes possible. Its shape is:
export interface ArgumentMetadata {
type: 'body' | 'query' | 'param' | 'custom';
metatype?: new (...args: any[]) => unknown;
data?: string;
}
metatype is the TypeScript class declared in the handler signature (for instance a DTO), which validation pipes inspect to decide how to validate.
A transformation pipe
Route and query parameters always arrive as strings. A transformation pipe is the idiomatic way to coerce them. Here is a pipe that parses an integer and rejects anything that is not numeric.
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
transform(value: string, metadata: ArgumentMetadata): number {
const parsed = parseInt(value, 10);
if (isNaN(parsed)) {
throw new BadRequestException(
`Validation failed: "${value}" is not an integer`,
);
}
return parsed;
}
}
Bind it directly to a parameter, and the handler receives a real number:
import { Controller, Get, Param } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return { id, typeofId: typeof id };
}
}
Output:
$ curl http://localhost:3000/users/42
{"id":42,"typeofId":"number"}
$ curl http://localhost:3000/users/abc
{"statusCode":400,"message":"Validation failed: \"abc\" is not an integer","error":"Bad Request"}
A validation pipe
A validation pipe inspects the value (typically against a DTO’s metatype) and either returns it unchanged when valid or throws when not. The defining trait is that it never mutates the payload — it only acts as a gatekeeper.
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
@Injectable()
export class NonEmptyBodyPipe implements PipeTransform {
transform(value: unknown, metadata: ArgumentMetadata): unknown {
if (metadata.type !== 'body') {
return value;
}
if (!value || Object.keys(value).length === 0) {
throw new BadRequestException('Request body must not be empty');
}
return value;
}
}
In practice you rarely hand-write validation pipes. NestJS ships a built-in
ValidationPipethat validates DTOs declaratively usingclass-validatordecorators — reach for that before building your own.
Transformation vs. validation
| Aspect | Transformation pipe | Validation pipe |
|---|---|---|
| Goal | Change the value’s shape or type | Confirm the value is acceptable |
| Return value | A new/converted value | The original value, untouched |
| On failure | Usually still returns (or throws on parse) | Throws an exception, handler never runs |
| Typical example | ParseIntPipe, ParseUUIDPipe | ValidationPipe over a DTO |
A single pipe can do both — ValidationPipe with transform: true, for example, validates a DTO and returns a real class instance — but keeping the two concerns mentally distinct helps you reason about behavior.
Where pipes are applied
Pipes can be bound at four levels, from narrowest to widest scope: per-parameter, per-handler (@UsePipes() on a method), per-controller (@UsePipes() on the class), and globally via app.useGlobalPipes(...). Global pipes are the common choice for application-wide validation.
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.listen(3000);
}
bootstrap();
Best Practices
- Implement
PipeTransform<T, R>with explicit generics so the input and output types are checked at compile time. - Keep each pipe focused on a single concern — pure transformation or pure validation — for predictable, composable behavior.
- Throw NestJS
HttpExceptionsubclasses (such asBadRequestException) from pipes so failures map to correct HTTP status codes automatically. - Prefer the built-in
ValidationPipeplusclass-validatorDTOs over hand-rolled validation logic. - Register cross-cutting validation as a global pipe in
main.tsrather than repeating it on every handler. - Use
metadata.typeandmetadata.metatypeto make pipes reusable across@Body(),@Param(), and@Query()arguments.