Pipes, filters and interceptors
Three things run around your handler rather than inside it: pipes transform and validate what goes in, filters turn thrown exceptions into responses, and interceptors wrap the whole thing. Together they remove most of the repetition from the Express example in the first lesson.
Pipes
A pipe runs on an argument before the handler receives it. It either returns a transformed value or throws.
Built-in pipes
import { ParseIntPipe, ParseUUIDPipe, DefaultValuePipe } from "@nestjs/common";
@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number) {
return this.products.findOne(id); // a real number
}
@Get()
findAll(@Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number) {
return this.products.findAll({ page });
}
This is the fix for "every URL value is a string". ParseIntPipe converts, and
throws a 400 if it cannot:
{ "statusCode": 400, "message": "Validation failed (numeric string is expected)" }
Note the order: DefaultValuePipe first, so a missing page becomes 1 before
ParseIntPipe sees it. Reversed, ParseIntPipe would receive undefined and
fail.
| Pipe | Does |
|---|---|
ParseIntPipe |
string → number, 400 if not numeric |
ParseFloatPipe |
string → float |
ParseBoolPipe |
"true"/"false" → boolean |
ParseUUIDPipe |
validates a UUID |
ParseArrayPipe |
comma-separated → array |
DefaultValuePipe |
substitutes a default for undefined |
ValidationPipe |
validates a DTO — the important one |
ValidationPipe
Turn it on globally once:
// apps/api/src/main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
Those three options matter:
whitelist: true strips properties not declared on the DTO. Without it, a
client can send { name: "x", isAdmin: true } and that extra field flows into
your code — and into a database update if you spread the DTO. This is a real
privilege-escalation route.
forbidNonWhitelisted: true rejects instead of stripping, which surfaces
mistakes rather than silently ignoring them.
transform: true turns the plain body into an instance of your DTO class.
Do not enable implicit conversion
You will see this recommended widely:
transformOptions: { enableImplicitConversion: true }
It converts query strings to the declared TypeScript types, so a page: number
arrives as a number without you asking. Convenient, and it silently overrides
your explicit transforms.
The case that bites:
@Transform(({ value }) => value === true || value === "true")
@IsBoolean()
inStock?: boolean;
With implicit conversion on, ?inStock=false arrives as true. The
implicit converter runs its own boolean coercion and wins, so "false" — a
non-empty string — becomes true. Your transform is ignored and nothing warns
you.
The result is a filter that does the opposite of what was asked, with no error anywhere. Verify it yourself:
plainToInstance(Dto, { inStock: "false" }, { enableImplicitConversion: true });
// => { inStock: true }
plainToInstance(Dto, { inStock: "false" }, { enableImplicitConversion: false });
// => { inStock: false }
Convert explicitly instead. @Type(() => Number) for numbers, @Transform
for booleans. Three extra decorators across a DTO, and the behaviour is the one
you wrote.
DTOs and the validation decorators are the next module's first lesson; the pipe is what enforces them.
A custom pipe
import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common";
@Injectable()
export class ParseSlugPipe implements PipeTransform<string, string> {
transform(value: string): string {
const slug = value.trim().toLowerCase();
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
throw new BadRequestException(`"${value}" is not a valid slug`);
}
return slug;
}
}
@Get(":slug")
findOne(@Param("slug", ParseSlugPipe) slug: string) { ... }
Now every handler taking a slug gets a validated, normalised one, and no handler repeats the check.
Exception filters
Built-in exceptions already produce sensible responses. A filter takes over when you want a consistent shape across the whole API:
// apps/api/src/common/filters/http-exception.filter.ts
import {
ArgumentsHost, Catch, ExceptionFilter, HttpException,
HttpStatus, Logger,
} from "@nestjs/common";
import { Request, Response } from "express";
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.getResponse()
: "Something went wrong";
if (status >= 500) {
this.logger.error(
`${request.method} ${request.url}`,
exception instanceof Error ? exception.stack : String(exception),
);
}
response.status(status).json({
statusCode: status,
path: request.url,
timestamp: new Date().toISOString(),
...(typeof message === "object" ? message : { message }),
});
}
}
app.useGlobalFilters(new AllExceptionsFilter());
The important behaviour: a 500 logs the stack and returns a generic message. Anything else returns its own message. An unexpected error must never send a database error or a file path to a client, and doing it here means no individual handler has to remember.
@Catch() with no argument catches everything. @Catch(PrismaClientKnownRequestError)
would catch one type — useful for turning a unique-constraint violation into a
409.
Interceptors
An interceptor wraps the handler, so it can act before and after.
Logging:
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger("HTTP");
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const { method, url } = context.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap(() => this.logger.log(`${method} ${url} — ${Date.now() - start}ms`)),
);
}
}
Stripping sensitive fields, which is the structural fix for the password-hash problem from the controllers lesson:
@Injectable()
export class SerializeInterceptor implements NestInterceptor {
intercept(_: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
map((data) => instanceToPlain(data, { excludeExtraneousValues: true })),
);
}
}
With class-transformer, fields marked @Exclude() never leave the API — so
forgetting a select stops being a security incident.
next.handle() returns an RxJS Observable, which is why .pipe(tap(...))
appears. You do not need to learn RxJS to use interceptors; tap for side
effects and map for transforming the result cover almost everything.
The order, and why it matters
request → middleware → guard → interceptor (before) → pipe → handler
↓
response ← filter ← interceptor (after) ← ──────────────────────┘
Consequences worth knowing:
Guards run before pipes. An unauthenticated request with an invalid body gets 401, not 400. Correct — you should not tell an anonymous caller what is wrong with their payload.
Pipes run after interceptors start. A logging interceptor sees the request before validation, so it logs requests that later fail validation. Usually what you want.
Filters run last. Anything thrown anywhere — guard, pipe, handler, service — ends up there.
Wiring them up
// apps/api/src/main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("api");
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
// No enableImplicitConversion — it overrides explicit @Transform.
}),
);
app.useGlobalFilters(new AllExceptionsFilter());
app.useGlobalInterceptors(new LoggingInterceptor());
await app.listen(process.env.PORT ?? 3001);
}
Global versions registered here cannot inject dependencies. When one needs a service, register it as a provider instead:
@Module({
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }],
})
APP_FILTER, APP_PIPE, APP_GUARD and APP_INTERCEPTOR come from
@nestjs/core and are the form to use when injection is needed.
Check your work
Why DefaultValuePipe before ParseIntPipe: pipes run in order, so the
default must be substituted before the parse, or the parse receives undefined.
What whitelist: true prevents: extra properties reaching your code —
including something like isAdmin: true flowing into a database update.
Why not to enable implicit conversion: it overrides explicit @Transform
decorators, so ?inStock=false arrives as true and the filter does the
opposite of what was asked, silently.
Difference between whitelist and forbidNonWhitelisted: the first strips
unknown fields silently, the second rejects the request with a 400.
What a global exception filter must do with a 500: log the stack and return a generic message, so no internal detail reaches the client.
Why guards run before pipes: an unauthenticated request should be rejected before its body is inspected, so it gets 401 rather than 400.
Where a thrown exception ends up: the exception filter, wherever it was thrown from.
When to use APP_FILTER instead of useGlobalFilters: when the filter needs
dependency injection.
Practice
- Add
ParseIntPipeto a numeric route parameter. Passabcand read the 400. - Add
DefaultValuePipeforpage. Reverse the order and read the failure. - Enable the global
ValidationPipewithwhitelist,forbidNonWhitelistedandtransform. - Send an extra field not on your DTO. Confirm
forbidNonWhitelistedrejects it, then set it to false and confirm the field is silently stripped. - Write
ParseSlugPipeand use it. PassNot A Slug!and read the message. - Add the global exception filter. Throw a
NotFoundExceptionand a plainErrorand compare the responses and your logs. - Add the logging interceptor. Confirm timings appear for every request.
- Add
enableImplicitConversion: trueand a boolean DTO field with a@Transform. Send?flag=falseand confirm it arrives astrue. Remove the option and confirm it arrives asfalse. - Send an unauthenticated request with an invalid body to a guarded route. Confirm you get 401, not 400, and explain why.
- Convert the global filter to
APP_FILTERand inject a service into it.
Next: configuration and secrets.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship