What are exception filters in NestJS?
Exception filters in NestJS are classes that intercept unhandled exceptions before the response leaves the server, giving you full control over the status code, response body, and side effects like logging.
Theory
TL;DR
- Think of a filter as a mailroom sorter: it catches bad envelopes (thrown errors) and decides what to send back instead of letting them bounce randomly
- The built-in global filter handles
HttpExceptionautomatically; custom filters let you target specific types, add logging, or reshape the response @Catch(HttpException)targets only NestJS HTTP errors;@Catch()with no args catches everything including rawError- Register globally via
APP_FILTERin a module (supports DI) orapp.useGlobalFilters()inmain.ts(no DI) - Filters run after an exception is thrown but before the response is sent
Quick example
// http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException) // targets HttpException and all its subclasses
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: exception.getResponse(),
});
}
}Any HttpException thrown anywhere in the app now returns a consistent JSON shape with timestamp and path. The built-in NestJS default no longer runs for matched types.
Key difference
NestJS ships with a global exception handler that formats a basic JSON response for HttpException subclasses. Custom filters override or extend this by implementing ExceptionFilter and declaring via @Catch() which exception types to handle. The main benefit is a single place for logging, Sentry calls, or custom fields without touching every controller. That beats try/catch blocks scattered across handlers.
When to use
- App-wide consistent error format: register a global filter via
APP_FILTER - Route-specific behavior (for example,
/adminerrors go to Slack): use@UseFilters()on a controller or method - Only certain exception types need special handling:
@Catch(BadRequestException)or@Catch(ValidationError) - Side effects like Sentry or Prometheus without changing business logic: add them inside
catch() - Safety net for unhandled programmer errors:
@Catch()with no args maps everything to 500
How NestJS processes filters
When an exception bubbles up from a controller, NestJS checks @Catch() decorator metadata registered at bootstrap. The first filter whose type argument matches the thrown exception runs catch(). Inside, ArgumentsHost gives you the Express Request and Response objects. If no custom filter matches, BaseExceptionFilter handles standard HttpException instances.
Filter order with multiple globals is reversed at runtime. The last provider registered for APP_FILTER fires first for matching exceptions. I discovered this after wondering why my logger filter was not running before the response went out. Register catch all filters last.
Common mistakes
-
Making
catch()async. NestJS filters are synchronous. Anasync catch()that awaits anything can leave the response hanging indefinitely.typescript// Wrong - response hangs async catch(exception: HttpException, host: ArgumentsHost) { await sendToSlack(exception.message); // response never sent } // Fix - fire and forget for async side effects catch(exception: HttpException, host: ArgumentsHost) { setImmediate(() => sendToSlack(exception.message)); // write response synchronously below } -
Forgetting
multi: truewhen chaining global filters. Without it, a singleAPP_FILTERprovider can replace the built-in handler, leaving some error types with no fallback.typescript// Wrong { provide: APP_FILTER, useClass: MyFilter } // Fix { provide: APP_FILTER, useClass: MyFilter, multi: true } -
Using
@Catch()with no args everywhere. It grabsTypeErrorandReferenceErrortoo, masking real programmer bugs as generic 500s.typescript// Risky - TypeErrors disappear into 500 @Catch() // Safer for most filters @Catch(HttpException) -
Expecting
@UseFilters()to stack at controller and method level. The method-level filter overrides the controller-level one for that endpoint. They do not stack.
Real-world usage
- NestJS + Prisma: catch
PrismaClientKnownRequestErrorwith codeP2002and return409 Conflictwith the duplicate field name - Sentry via
nestjs-sentry: filter callsSentry.captureException()before sending the response - Validation with
class-validator: catchBadRequestException, extract field errors from the body, return a structurederrorsarray - GraphQL APIs:
@Catch(ApolloError)shapes GQL error responses independently from REST endpoints - Microservice gateway: logging and tracing filters at the gateway; domain-specific error details at individual services
Follow-up questions
Q: How do you apply a filter to just one method?
A: Add @UseFilters(MyFilter) directly on the method. It overrides any controller-level or global filter for that specific endpoint.
Q: What is the difference between @Catch(HttpException) and @Catch()?
A: @Catch(HttpException) only intercepts NestJS HTTP exceptions, letting raw Error objects fall through to the built-in handler. @Catch() catches every throwable, including TypeError and ReferenceError.
Q: Can a filter access data set by a guard or interceptor?
A: No. Filters run in the error phase after the execution chain has failed. You can only access request data via ArgumentsHost. Guard or interceptor context is not available at that point.
Q: How does filter order work when you register multiple globals?
A: NestJS reverses the registration order at runtime. The last APP_FILTER provider registered fires first for a matching exception type. Register broad filters last or they will shadow typed ones.
Q: In a microservice setup with a gateway, where should filters live?
A: Domain-specific filters (like a NotFoundException that includes entity details) belong to individual services. Cross-cutting filters for logging, alerting, or tracing belong to the gateway. Services stay independent; the gateway handles observability.
Examples
Basic: global catch all filter
A solid starting point for production apps. Catches every thrown value and returns a consistent JSON body with logging.
// all-exceptions.filter.ts
import {
ExceptionFilter, Catch, ArgumentsHost,
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()
: 'Internal server error';
this.logger.error(`${request.method} ${request.url}`, String(exception));
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
// app.module.ts
@Module({
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }],
})
export class AppModule {}Every unhandled exception now returns { statusCode, message, timestamp, path }. Registering via APP_FILTER instead of useGlobalFilters() keeps dependency injection working inside the filter.
Intermediate: validation filter on a signup endpoint
Real scenario from auth flows. class-validator throws BadRequestException with an array of field errors inside the response body. This filter extracts them into a structured shape.
// validation.filter.ts
import { Catch, ArgumentsHost, BadRequestException } from '@nestjs/common';
import { Response, Request } from 'express';
@Catch(BadRequestException)
export class ValidationFilter implements ExceptionFilter {
catch(exception: BadRequestException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const body = exception.getResponse() as any;
response.status(400).json({
statusCode: 400,
message: 'Validation failed',
errors: body.message, // array: ['email must be an email']
path: request.url,
});
}
}
// auth.controller.ts
@Controller('auth')
@UseFilters(ValidationFilter)
export class AuthController {
@Post('signup')
signup(@Body() dto: CreateUserDto) {
return this.authService.signup(dto);
}
}POST /auth/signup with an invalid email now returns { statusCode: 400, message: "Validation failed", errors: ["email must be an email"] } instead of the default NestJS format.
Advanced: Prisma error mapping
Catching database-level constraint violations and translating them into proper HTTP responses. Common in any NestJS + Prisma codebase.
// prisma-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Response, Request } from 'express';
@Catch(Prisma.PrismaClientKnownRequestError)
export class PrismaExceptionFilter implements ExceptionFilter {
catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// P2002 = unique constraint violation
const status = exception.code === 'P2002'
? HttpStatus.CONFLICT
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception.code === 'P2002'
? `Duplicate value on: ${(exception.meta?.target as string[])?.join(', ')}`
: 'Database error';
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}POST /users with a duplicate email no longer returns a raw Prisma stack trace or a generic 500. The client gets { statusCode: 409, message: "Duplicate value on: email" }. Register this filter globally or per module depending on whether Prisma is used app-wide.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.