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
ValidationPipefor DTO validation;ParseIntPipe,ParseUUIDPipefor primitives- Decision rule: always pipe incoming HTTP data; skip it only for trusted internal calls
Quick example
// 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
ValidationPipewith a DTO class and class-validator decorators. - Optional query params with defaults: chain
DefaultValuePipebeforeParseIntPipe. - Global validation:
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }))inmain.ts. - Business-level checks (email uniqueness, DB existence): custom pipe implementing
PipeTransform.
Built-in pipes
| Pipe | What it does | Example |
|---|---|---|
ValidationPipe | Validates DTOs via class-validator | { age: "25" } → { age: 25 } or 400 |
ParseIntPipe | string → integer, rejects non-integers | "123" → 123; "abc" → 400 |
ParseFloatPipe | string → float | "12.5" → 12.5 |
ParseBoolPipe | string → boolean | "true" → true |
ParseUUIDPipe | Validates UUID format | invalid UUID → 400 |
ParseEnumPipe | Validates against an enum | value not in enum → 400 |
DefaultValuePipe | Returns default when param is missing | no limit → 10 |
ParseArrayPipe | Comma-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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.tsin most NestJS apps: globalValidationPipewithwhitelist: trueandtransform: true- Auth endpoints:
ParseUUIDPipeon@Param('userId')rejects malformed IDs before hitting the DB - Prisma apps: custom pipes validate enum values before passing to Prisma queries
- GraphQL resolvers:
ValidationPipeon@Args()works the same way as on REST controllers - NestJS microservices: pipes validate
MessagePatternpayloads using the samePipeTransformAPI
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
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
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 detailsThe 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
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 readyA concise answer to help you respond confidently on this topic during an interview.