Skip to main content

What are pipes in NestJS?

Pipes in NestJS are functions that intercept incoming request data (params, query, body) before it reaches the route handler, implementing the PipeTransform interface. They either transform the raw value into the correct type or throw an HTTP 400 if the input is invalid.

Theory

TL;DR

  • Think of a pipe as an airport security scanner: raw strings from the URL get checked and converted; bad input is rejected at the door, valid input passes through as the right type
  • Two jobs: transform ("123"123) and validate (reject non-integers, invalid emails, malformed UUIDs)
  • HTTP always delivers params and query values as strings. Pipes fix that automatically
  • ValidationPipe for DTO validation; ParseIntPipe, ParseUUIDPipe for primitives
  • Decision rule: always pipe incoming HTTP data; skip it only for trusted internal calls

Quick example

typescript
// Without pipe: id is always a string from the URL @Get(':id') findOne(@Param('id') id: string) { return this.usersService.findOne(+id); // manual parse, crashes on "abc" } // With ParseIntPipe: auto-parses and validates, throws 400 on failure @Get(':id') findOne(@Param('id', ParseIntPipe) id: number) { return this.usersService.findOne(id); // id is number or 400 Bad Request } // GET /users/123 → id = 123 // GET /users/abc → 400 "Validation failed (numeric string is expected)"

ParseIntPipe runs before the handler body. If the value is not a valid integer, NestJS throws BadRequestException and the handler never executes.

Key difference from manual parsing

Manual parsing inside the handler (+id, parseInt(id)) mixes data validation with business logic. On bad input it silently produces NaN, which then propagates into your service and causes failures far from the source. Pipes catch bad data at the boundary, before any logic runs, and return a consistent 400 response.

When to use

  • URL params and query strings: always apply ParseIntPipe, ParseUUIDPipe, or similar. Express delivers them as strings.
  • POST/PUT request body: use ValidationPipe with a DTO class and class-validator decorators.
  • Optional query params with defaults: chain DefaultValuePipe before ParseIntPipe.
  • Global validation: app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })) in main.ts.
  • Business-level checks (email uniqueness, DB existence): custom pipe implementing PipeTransform.

Built-in pipes

PipeWhat it doesExample
ValidationPipeValidates DTOs via class-validator{ age: "25" }{ age: 25 } or 400
ParseIntPipestring → integer, rejects non-integers"123" → 123; "abc" → 400
ParseFloatPipestring → float"12.5" → 12.5
ParseBoolPipestring → boolean"true" → true
ParseUUIDPipeValidates UUID formatinvalid UUID → 400
ParseEnumPipeValidates against an enumvalue not in enum → 400
DefaultValuePipeReturns default when param is missingno limit → 10
ParseArrayPipeComma-separated string → array"1,2,3" → [1, 2, 3]

Binding levels

Pipes can be applied at four levels. More specific always wins over less specific.

typescript
// 1. Parameter level (most specific) @Get(':id') findOne(@Param('id', ParseIntPipe) id: number) {} // 2. Method level @Post() @UsePipes(ValidationPipe) create(@Body() dto: CreateUserDto) {} // 3. Controller level @Controller('users') @UsePipes(new ValidationPipe()) export class UsersController {} // 4. Global (in main.ts) app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); // or via DI token: { provide: APP_PIPE, useClass: ValidationPipe }

Global pipes run on every request across the entire app. That is the right default for ValidationPipe. For type-specific parsing like ParseIntPipe, apply it at the parameter level so it only runs where a number is actually expected.

How NestJS executes pipes

During route registration, NestJS binds pipes to specific arguments using metadata from decorators (@Param, @Body, @Query). On each request, it calls transform(value, metadata) on every pipe in sequence before passing arguments to the handler. If any transform() throws a BadRequestException, the exception filter returns 400 and the handler is never called.

Custom pipes can be async. NestJS awaits the result before continuing, so DB lookups inside transform() work without any extra setup.

Common mistakes

Mistake 1: new ValidationPipe() without { transform: true }.

Without it, DTO fields stay as strings even when typed as number. Validators like @IsNumber() then fail on the string value. From what I've seen in production codebases, this is the single most common pipe mistake. Everything looks correct until a numeric comparison silently breaks.

typescript
// Wrong: age stays "25" (string), @IsNumber() fails app.useGlobalPipes(new ValidationPipe()); // Correct app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));

Mistake 2: @Body(ValidationPipe) without a DTO class.

ValidationPipe expects a class with decorator metadata. Without a DTO it has nothing to validate against and throws a cryptic error.

typescript
// Wrong @Post() create(@Body(ValidationPipe) body: any) {} // Correct: always pair with a DTO @Post() create(@Body() dto: CreateUserDto) {}

Mistake 3: Wrong pipe order on the same parameter.

Pipes execute left to right. Putting ValidationPipe before ParseIntPipe means it tries to validate a string, not a number.

typescript
// Wrong: validates string first, then parses @Param('id', ValidationPipe, ParseIntPipe) // Correct: parse first, then validate @Param('id', ParseIntPipe, ValidationPipe)

Mistake 4: Throwing new Error() in a custom pipe.

A plain Error becomes a 500 Internal Server Error. Pipes sit in the HTTP layer and should throw NestJS HTTP exceptions.

typescript
// Wrong: 500 response throw new Error('Invalid value'); // Correct: 400 response throw new BadRequestException('Invalid value');

Mistake 5: Missing async/await in a custom pipe that does a DB lookup.

typescript
// Wrong: returns an unresolved Promise as the value transform(value: string) { const exists = this.service.exists(value); // no await! if (exists) throw new BadRequestException(); return value; } // Correct async transform(value: string) { const exists = await this.service.exists(value); if (exists) throw new BadRequestException('Already taken'); return value; }

Real-world usage

  • main.ts in most NestJS apps: global ValidationPipe with whitelist: true and transform: true
  • Auth endpoints: ParseUUIDPipe on @Param('userId') rejects malformed IDs before hitting the DB
  • Prisma apps: custom pipes validate enum values before passing to Prisma queries
  • GraphQL resolvers: ValidationPipe on @Args() works the same way as on REST controllers
  • NestJS microservices: pipes validate MessagePattern payloads using the same PipeTransform API

Follow-up questions

Q: What is the difference between a global pipe and a route-level pipe?
A: Global pipes run on every request across all controllers. Route or parameter-level pipes run only where decorated. Use global for universal validation (DTOs), use local for specific type coercion.

Q: Can a pipe access the full HTTP request object?
A: Not directly through transform() arguments. In a custom pipe you can inject REQUEST scope or use ExecutionContext. Most use cases do not need it.

Q: How does ValidationPipe handle nested DTOs?
A: Add @ValidateNested() to the nested property and @Type(() => NestedDto) from class-transformer. For arrays, add each: true on the validator decorator.

Q: What happens if the first of two pipes on a parameter throws?
A: Execution stops immediately. The second pipe never runs, the handler never executes, and the exception goes to the exception filter.

Q (Senior): You need to rate-limit by IP using Redis inside a pipe. What are the pitfalls?
A: You need request scope to read the IP, either via Inject(REQUEST) or ExecutionContext. The transform() must be async. Wrap the Redis call in a try/catch with a fallback; a Redis timeout without it becomes a 500. Test under concurrent load too: without atomic operations (INCR + EXPIRE in a single transaction), you hit race conditions where multiple requests slip through before the counter increments.

Examples

Basic: parsing a URL param

typescript
import { Controller, Get, Param } from '@nestjs/common'; import { ParseIntPipe } from '@nestjs/common'; @Controller('cats') export class CatsController { @Get(':id') findCat(@Param('id', ParseIntPipe) id: number) { // id is always a number here return `Cat #${id}`; } } // GET /cats/5 → "Cat #5" // GET /cats/foo → 400 "Validation failed (numeric string is expected)"

ParseIntPipe runs before the method body executes. The handler only sees valid integers.

Intermediate: DTO validation on a POST endpoint

typescript
import { IsEmail, IsInt, Min } from 'class-validator'; import { Body, Controller, Param, Post } from '@nestjs/common'; import { ParseIntPipe, ValidationPipe } from '@nestjs/common'; class UpdateUserDto { @IsEmail() email: string; @IsInt() @Min(18) age: number; } @Controller('users') export class UsersController { @Post(':id') updateUser( @Param('id', ParseIntPipe) id: number, @Body(new ValidationPipe({ transform: true })) dto: UpdateUserDto, ) { // id is number, email is validated, age is number >= 18 return this.usersService.update(id, dto); } } // POST /users/1 { "email": "test@example.com", "age": "20" } // → dto = { email: "test@example.com", age: 20 } // POST /users/1 { "email": "bad", "age": 15 } // → 400 with validation error details

The transform: true option converts "20" (string from JSON) to 20 (number) before @IsInt() runs. Without it the check would fail on a string.

Advanced: custom async pipe for a uniqueness check

typescript
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'; import { UsersService } from './users.service'; @Injectable() export class UniqueEmailPipe implements PipeTransform<string> { constructor(private readonly usersService: UsersService) {} async transform(value: string, metadata: ArgumentMetadata): Promise<string> { const exists = await this.usersService.emailExists(value); if (exists) { throw new BadRequestException(`Email "${value}" is already registered`); } return value; } } // Usage in controller @Post() async createUser( @Body('email', UniqueEmailPipe) email: string, ) { return this.usersService.create({ email }); }

Three things make this work correctly. UniqueEmailPipe is @Injectable(), so NestJS handles dependency injection and the service is available. transform() is async, which NestJS awaits before calling the handler. And BadRequestException is used, not a plain Error, so the response is 400 and not 500.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?