Skip to main content

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 HttpException automatically; 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 raw Error
  • Register globally via APP_FILTER in a module (supports DI) or app.useGlobalFilters() in main.ts (no DI)
  • Filters run after an exception is thrown but before the response is sent

Quick example

typescript
// 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, /admin errors 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

  1. Making catch() async. NestJS filters are synchronous. An async 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 }
  2. Forgetting multi: true when chaining global filters. Without it, a single APP_FILTER provider 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 }
  3. Using @Catch() with no args everywhere. It grabs TypeError and ReferenceError too, masking real programmer bugs as generic 500s.

    typescript
    // Risky - TypeErrors disappear into 500 @Catch() // Safer for most filters @Catch(HttpException)
  4. 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 PrismaClientKnownRequestError with code P2002 and return 409 Conflict with the duplicate field name
  • Sentry via nestjs-sentry: filter calls Sentry.captureException() before sending the response
  • Validation with class-validator: catch BadRequestException, extract field errors from the body, return a structured errors array
  • 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.

typescript
// 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.

typescript
// 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.

typescript
// 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 ready
Premium

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

Finished reading?