Custom Pipes
NestJS ships with a solid set of built-in pipes, but eventually you hit a transformation or validation rule that none of them cover. Custom pipes let you encapsulate that logic in a single, testable, reusable class. A pipe is just an @Injectable() class that implements the PipeTransform interface — Nest runs it against an argument before your route handler receives the value, giving you a clean place to coerce, normalize, or reject input.
The PipeTransform interface
Every pipe implements PipeTransform<T, R>, which declares a single method, transform(value, metadata). The value is the raw argument Nest extracted (from the body, a param, a query, etc.), and the return value becomes what your handler receives. If you throw inside transform, the handler never runs and the exception is sent to the client.
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
@Injectable()
export class TrimPipe implements PipeTransform<string, string> {
transform(value: string, metadata: ArgumentMetadata): string {
if (typeof value !== 'string') {
throw new BadRequestException('Expected a string value');
}
return value.trim();
}
}
Mark the class @Injectable() so Nest can construct it through the DI container — this lets a pipe inject services such as a repository or config provider.
Reading ArgumentMetadata
The second parameter, ArgumentMetadata, describes where the value came from and what type the handler expects. This is what makes a pipe generic: you can branch on the source or inspect the declared type at runtime.
| Property | Type | Meaning |
|---|---|---|
type | 'body' | 'query' | 'param' | 'custom' | Which decorator supplied the argument |
metatype | Type<unknown> | undefined | The TypeScript type of the argument (e.g. String, a DTO class) |
data | string | undefined | The string passed to the decorator, e.g. @Param('id') |
Here is a pipe that only validates body arguments backed by a class metatype, and ignores primitives:
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
@Injectable()
export class ManualValidationPipe implements PipeTransform {
transform(value: unknown, { metatype, type }: ArgumentMetadata) {
if (type !== 'body' || !this.shouldValidate(metatype)) {
return value;
}
const object = plainToInstance(metatype as any, value);
const errors = validateSync(object as object);
if (errors.length > 0) {
const messages = errors.flatMap((e) =>
Object.values(e.constraints ?? {}),
);
throw new BadRequestException(messages);
}
return object;
}
private shouldValidate(metatype?: ArgumentMetadata['metatype']): boolean {
const primitives: unknown[] = [String, Boolean, Number, Array, Object];
return !!metatype && !primitives.includes(metatype);
}
}
The
metatypeis only populated when TypeScript emits type metadata. Make sureemitDecoratorMetadataandexperimentalDecoratorsare enabled intsconfig.json, otherwisemetatypeisundefinedfor class arguments.
A parameterized custom pipe
Pipes become far more reusable when they accept options through a constructor. Because you instantiate the pipe yourself at the binding site (new MyPipe(...)), you can pass configuration per usage. The following pipe parses and validates a positive integer, with a configurable upper bound.
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
export interface IntPipeOptions {
min?: number;
max?: number;
}
@Injectable()
export class BoundedIntPipe implements PipeTransform<string, number> {
constructor(private readonly options: IntPipeOptions = {}) {}
transform(value: string, metadata: ArgumentMetadata): number {
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new BadRequestException(
`${metadata.data ?? 'value'} must be an integer`,
);
}
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } =
this.options;
if (parsed < min || parsed > max) {
throw new BadRequestException(
`${metadata.data ?? 'value'} must be between ${min} and ${max}`,
);
}
return parsed;
}
}
Bind it on a parameter, passing options through the constructor:
import { Controller, Get, Query } from '@nestjs/common';
import { BoundedIntPipe } from './bounded-int.pipe';
@Controller('products')
export class ProductsController {
@Get()
findAll(
@Query('page', new BoundedIntPipe({ min: 1, max: 1000 })) page: number,
) {
return { page, type: typeof page };
}
}
A request to GET /products?page=3 returns the coerced number, while an out-of-range value is rejected before the handler executes.
Output:
$ curl "http://localhost:3000/products?page=3"
{"page":3,"type":"number"}
$ curl "http://localhost:3000/products?page=9999"
{"statusCode":400,"message":"page must be between 1 and 1000","error":"Bad Request"}
Binding custom pipes
Custom pipes attach the same way built-in ones do — at the parameter, handler, controller, or global level.
// Parameter scope (can pass constructor options)
@Get(':id')
findOne(@Param('id', new BoundedIntPipe({ min: 1 })) id: number) {}
// Global scope (resolved by DI, supports injection)
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ManualValidationPipe());
When a pipe needs injected dependencies and must be global, register it as a provider with the APP_PIPE token so Nest resolves it through the container instead of new.
Best practices
- Implement
PipeTransform<T, R>with explicit generics so the return type is checked at compile time. - Throw
BadRequestException(or anotherHttpException) for invalid input rather than returningnull— this produces a correct HTTP status. - Use
ArgumentMetadata.typeto skip arguments your pipe should not touch (e.g. ignoreparamwhen you only validatebody). - Skip validation for primitive metatypes so a generic pipe does not choke on plain
string/numberarguments. - Prefer constructor options for per-usage configuration and
@Injectable()plusAPP_PIPEwhen the pipe needs services. - Keep pipes pure and side-effect free; they should transform or reject, not perform persistence or business logic.
- Unit test pipes directly by calling
transform()with a mockedArgumentMetadata— no HTTP layer required.