Skip to content
NestJS ns pipes 4 min read

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.

PropertyTypeMeaning
type'body' | 'query' | 'param' | 'custom'Which decorator supplied the argument
metatypeType<unknown> | undefinedThe TypeScript type of the argument (e.g. String, a DTO class)
datastring | undefinedThe 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 metatype is only populated when TypeScript emits type metadata. Make sure emitDecoratorMetadata and experimentalDecorators are enabled in tsconfig.json, otherwise metatype is undefined for 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 another HttpException) for invalid input rather than returning null — this produces a correct HTTP status.
  • Use ArgumentMetadata.type to skip arguments your pipe should not touch (e.g. ignore param when you only validate body).
  • Skip validation for primitive metatypes so a generic pipe does not choke on plain string/number arguments.
  • Prefer constructor options for per-usage configuration and @Injectable() plus APP_PIPE when 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 mocked ArgumentMetadata — no HTTP layer required.
Last updated June 14, 2026
Was this helpful?