NestJS Day 4: What are Exception Filters?
Abstract
This is a concise, summarized approach to learn NestJS. For more in-depth knowledge about NestJS, visit the official NestJS documentation.
Exception filters in NestJS are powerful components that handle all unhandled exceptions across your application. They provide a centralized way to catch and process errors, format responses, and implement custom error handling logic.
Table of Contents
- What are Exception Filters?
- Built-in Exception Handling
- Built-in HTTP Exceptions
- Custom Exception Filters
- Global Exception Filters
- Method-scoped and Controller-scoped Filters
- Catch-all Exception Filters
- Extending Base Exception Filter
- Best Practices
- Real-world Examples
1. What are Exception Filters?
Exception filters are responsible for processing all unhandled exceptions in your NestJS application. When an exception is thrown and not caught by your application code, it’s automatically handled by the exception filter layer.
Key Benefits:
- Centralized error handling: All exceptions are processed in one place
- Consistent response format: Standardize error responses across your API
- Custom logic: Add logging, monitoring, or custom response formatting
- Different scopes: Apply filters globally, per controller, or per method
2. Built-in Exception Handling
NestJS comes with a built-in global exception filter that automatically handles exceptions of type HttpException
and its subclasses.
Default Response Format
When an unrecognized exception occurs (not an HttpException
), the default response is:
{ "statusCode": 500, "message": "Internal server error"}
Throwing Standard Exceptions
The HttpException
class is the base for all HTTP exceptions in NestJS:
// Basic HttpExceptionthrow new HttpException("Forbidden", HttpStatus.FORBIDDEN);
// Custom response objectthrow new HttpException( { status: HttpStatus.FORBIDDEN, error: "This is a custom message", }, HttpStatus.FORBIDDEN);
// With error cause for loggingthrow new HttpException("Forbidden access!", HttpStatus.FORBIDDEN, { cause: new Error("User is not authorized"), description: "User does not have permission",});
3. Built-in HTTP Exceptions
NestJS provides a comprehensive set of built-in HTTP exceptions:
Common HTTP Exceptions
import { BadRequestException, UnauthorizedException, NotFoundException, ForbiddenException, NotAcceptableException, RequestTimeoutException, ConflictException, InternalServerErrorException,} from "@nestjs/common";
// Basic usagethrow new BadRequestException();throw new ForbiddenException();throw new NotFoundException();
// With custom messagesthrow new BadRequestException("Invalid input data");throw new ForbiddenException("Access denied");
// With optionsthrow new BadRequestException("Something bad happened", { cause: new Error(), description: "Some error description",});
Complete List of Built-in Exceptions
BadRequestException (400)
- Invalid input validation: Missing required fields, wrong data types, or malformed JSON
- Invalid query parameters: Negative page numbers, invalid date formats, or unsupported filters
- Business rule violations: Age restrictions, invalid combinations of parameters
- Malformed requests: Invalid email formats, phone numbers, or URLs
if (!email || !isValidEmail(email)) { throw new BadRequestException("Valid email is required");}
UnauthorizedException (401)
- Missing authentication: No JWT token provided in request headers
- Invalid credentials: Wrong username/password combination during login
- Expired tokens: JWT token has expired and needs refresh
- Invalid API keys: Malformed or non-existent API keys
if (!token || !this.jwtService.verify(token)) { throw new UnauthorizedException("Invalid or missing authentication token");}
NotFoundException (404)
- Resource not found: User, product, or entity doesn’t exist by given ID
- Endpoint not found: Accessing non-existent API routes
- File not found: Requested document or image doesn’t exist
- Empty search results: No matching records for search criteria
const user = await this.userService.findById(id);if (!user) { throw new NotFoundException(`User with ID ${id} not found`);}
ForbiddenException (403)
- Insufficient permissions: User lacks required role or permission
- Resource ownership: Accessing another user’s private data
- Account restrictions: Suspended or banned user accounts
- Feature access: Premium features for free tier users
if (user.role !== "admin" && resource.ownerId !== user.id) { throw new ForbiddenException("Access denied to this resource");}
NotAcceptableException (406)
- Unsupported response format: Client requests XML but only JSON available
- Unsupported language: Requested localization not supported
- Version incompatibility: API version not supported
- Content negotiation failure: No acceptable content type match
if (!acceptHeader.includes("application/json")) { throw new NotAcceptableException("Only JSON format is supported");}
RequestTimeoutException (408)
- Long-running operations: Database queries exceeding timeout limits
- External API delays: Third-party service taking too long to respond
- File processing timeout: Large file uploads or processing operations
- Background job timeout: Scheduled tasks exceeding time limits
const timeoutPromise = new Promise((_, reject) => setTimeout( () => reject(new RequestTimeoutException("Operation timed out")), 30000 ));
ConflictException (409)
- Duplicate resources: Email already registered, username taken
- Concurrent modifications: Version conflicts in optimistic locking
- State conflicts: Trying to delete a resource that’s in use
- Business rule conflicts: Overlapping appointments or bookings
const existingUser = await this.userService.findByEmail(email);if (existingUser) { throw new ConflictException("Email already registered");}
GoneException (410)
- Deprecated API endpoints: Old API versions no longer available
- Deleted resources: Previously existing resource has been permanently removed
- Expired content: Time-limited offers or content that’s no longer valid
- Migrated resources: Data moved to new location permanently
if (apiVersion < MINIMUM_SUPPORTED_VERSION) { throw new GoneException("This API version is no longer supported");}
HttpVersionNotSupportedException (505)
- Outdated HTTP protocol: Client using HTTP/1.0 when HTTP/1.1+ required
- Protocol mismatch: Expecting HTTP/2 but receiving HTTP/1.1
- Legacy client compatibility: Old clients not supporting required HTTP features
- Security requirements: Enforcing newer HTTP versions for security
if (request.httpVersion < "1.1") { throw new HttpVersionNotSupportedException("HTTP/1.1 or higher required");}
PayloadTooLargeException (413)
- File upload limits: Images, documents, or videos exceeding size limits
- Request body size: JSON payloads too large for processing
- Bulk operations: Too many items in batch requests
- Memory constraints: Preventing server overload from large requests
if (file.size > MAX_FILE_SIZE) { throw new PayloadTooLargeException("File size exceeds 10MB limit");}
UnsupportedMediaTypeException (415)
- Wrong file formats: Uploading unsupported file types (e.g., .exe instead of .jpg)
- Content-Type mismatch: Sending XML with JSON Content-Type header
- API format restrictions: Endpoint only accepts specific formats
- Security restrictions: Blocking potentially dangerous file types
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { throw new UnsupportedMediaTypeException( "Only JPG, PNG, and PDF files allowed" );}
UnprocessableEntityException (422)
- Validation failures: Data format correct but business rules violated
- Semantic errors: Valid JSON but invalid business logic
- Dependency failures: Referenced entities don’t exist or are invalid
- Complex validation: Multi-field validation failures
if (startDate > endDate) { throw new UnprocessableEntityException("Start date must be before end date");}
InternalServerErrorException (500)
- Database connection failures: Unable to connect to database
- Unexpected exceptions: Unhandled errors in application logic
- Third-party service failures: External API returning errors
- Configuration errors: Missing environment variables or settings
try { await this.databaseService.connect();} catch (error) { throw new InternalServerErrorException("Database connection failed");}
NotImplementedException (501)
- Feature under development: Endpoints planned but not yet implemented
- Platform-specific features: Features only available on certain platforms
- Conditional functionality: Features available only under certain conditions
- Placeholder endpoints: API design completed but implementation pending
if (feature === "advanced_analytics") { throw new NotImplementedException("Advanced analytics coming soon");}
ImATeapotException (418)
- Easter eggs: Fun responses for special occasions or testing
- API humor: Light-hearted responses to unusual requests
- Coffee/tea jokes: Playful responses to beverage-related endpoints
- Testing purposes: Unique status code for testing HTTP client handling
if (request.body.beverage === "coffee" && this.isTeapot) { throw new ImATeapotException("I'm a teapot, I can't brew coffee!");}
MethodNotAllowedException (405)
- HTTP method restrictions: GET request to POST-only endpoint
- Resource state limitations: DELETE on read-only resources
- Authentication level restrictions: Methods requiring higher auth levels
- Maintenance mode: Temporarily disabling write operations
if (request.method === "DELETE" && resource.isReadOnly) { throw new MethodNotAllowedException( "DELETE not allowed on read-only resources" );}
BadGatewayException (502)
- Upstream service failures: Payment gateway or email service errors
- Proxy errors: Issues with load balancers or reverse proxies
- Microservice communication: Service-to-service communication failures
- External API integration: Third-party APIs returning invalid responses
try { const response = await this.paymentGateway.processPayment(data);} catch (error) { throw new BadGatewayException("Payment gateway is currently unavailable");}
ServiceUnavailableException (503)
- Maintenance mode: Scheduled maintenance or deployments
- System overload: Server at capacity, temporarily rejecting requests
- Database maintenance: Database backup or migration in progress
- Circuit breaker pattern: Preventing cascade failures by temporarily blocking requests
if (this.isMaintenanceMode) { throw new ServiceUnavailableException( "Service temporarily unavailable for maintenance" );}
GatewayTimeoutException (504)
- Upstream timeouts: External services taking too long to respond
- Network issues: Connectivity problems with dependencies
- Load balancer timeouts: Proxy timeouts waiting for backend services
- Chain service failures: Multiple service calls where one times out
try { const result = await Promise.race([ this.externalService.getData(), this.timeoutPromise(30000), ]);} catch (error) { throw new GatewayTimeoutException("External service timeout");}
PreconditionFailedException (412)
- Conditional requests: If-Match header conditions not met
- State validation: Resource state doesn’t match expected conditions
- Version control: Entity version mismatch in optimistic locking
- Business preconditions: Required conditions not met before operation
if (request.headers["if-match"] !== resource.etag) { throw new PreconditionFailedException( "Resource has been modified by another request" );}
Best Practices
- Provide meaningful messages: Include specific details about what went wrong
- Use appropriate status codes: Match the HTTP status to the actual problem
- Include error codes: Add custom error codes for client-side handling
- Log appropriately: Log server errors (5xx) but not client errors (4xx)
- Return helpful information: Guide users on how to fix the issue when possible
Custom Exception Filters
Create custom exception filters to implement specific error handling logic:
Basic HTTP Exception Filter
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, ForbiddenException, BadRequestException,} from "@nestjs/common";import { Request, Response } from "express";
@Catch(BadRequestException, ForbiddenException)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();
const errorResponse = { statusCode: status, timestamp: new Date().toISOString(), path: request.url, method: request.method, message: exception.message || "An error occurred", };
response.status(status).json(errorResponse); }}
Custom Exception Classes
Create your own exception classes for domain-specific errors:
import { HttpException, HttpStatus } from "@nestjs/common";
export class CustomForbiddenException extends HttpException { constructor(message?: string) { super(message || "Custom forbidden message!", HttpStatus.FORBIDDEN); }}
// Usagethrow new CustomForbiddenException( "You are not allowed to access this resource!");
5. Global Exception Filters
Method 1: Using useGlobalFilters()
import { NestFactory } from "@nestjs/core";import { AppModule } from "./app.module";import { HttpExceptionFilter } from "./filters/http-exception.filter";
async function bootstrap() { const app = await NestFactory.create(AppModule);
// Global filter with instance app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);}bootstrap();
Method 2: Using Dependency Injection (Recommended)
import { Module } from "@nestjs/common";import { APP_FILTER } from "@nestjs/core";import { HttpExceptionFilter } from "./filters/http-exception.filter";
@Module({ providers: [ { provide: APP_FILTER, useClass: HttpExceptionFilter, }, ],})export class AppModule {}
Advantages of Method 2:
- Enables dependency injection
- Better testability
- Framework manages instantiation
6. Method-scoped and Controller-scoped Filters
Method-scoped Filter
// controller method@Post('forbidden-access')@UseFilters(new HttpExceptionFilter()) // Instance// OR@UseFilters(HttpExceptionFilter) // Class (recommended)forbiddenAccess() { throw new ForbiddenException();}
Controller-scoped Filter
@Controller("exception-filters")@UseFilters(HttpExceptionFilter)export class ExceptionFiltersController { // All methods in this controller use the filter
@Get("test-exception") testException() { throw new Error("This is a test exception"); }
@Post("forbidden-access") forbiddenAccess() { throw new ForbiddenException(); }}
7. Catch-all Exception Filters
Create filters that catch ALL exceptions, not just HTTP exceptions:
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,} from "@nestjs/common";import { HttpAdapterHost } from "@nestjs/core";
@Catch() // Empty decorator catches everythingexport class CatchEverythingFilter implements ExceptionFilter { constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void { // Get the HTTP adapter (platform-agnostic) const { httpAdapter } = this.httpAdapterHost; const ctx = host.switchToHttp();
// Determine HTTP status const httpStatus = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
// Create response body const responseBody = { statusCode: httpStatus, timestamp: new Date().toISOString(), path: httpAdapter.getRequestUrl(ctx.getRequest()), error: exception instanceof Error ? exception.message : "Unknown error", };
// Log the exception console.error("Unhandled exception:", exception);
// Send response httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus); }}
Usage with Global Registration
async function bootstrap() { const app = await NestFactory.create(AppModule);
const { httpAdapter } = app.get(HttpAdapterHost); app.useGlobalFilters(new CatchEverythingFilter(httpAdapter));
await app.listen(3000);}
8. Extending Base Exception Filter
For cases where you want to extend the built-in behavior:
import { Catch, ArgumentsHost } from "@nestjs/common";import { BaseExceptionFilter } from "@nestjs/core";
@Catch()export class AllExceptionsFilter extends BaseExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { // Custom logic (e.g., logging) console.error("An unhandled exception occurred:", exception);
// Call the base filter to handle the response super.catch(exception, host); }}
Important Notes:
- Method-scoped and Controller-scoped filters extending
BaseExceptionFilter
should NOT be instantiated withnew
- Let the framework instantiate them automatically
- For global filters, you can inject
HttpAdapter
reference
9. Best Practices
1. Use Class-based Filter Registration
// Preferred@UseFilters(HttpExceptionFilter)
// Avoid (unless you need specific configuration)@UseFilters(new HttpExceptionFilter())
2. Implement Proper Logging
@Catch(HttpException)export class LoggingExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(LoggingExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); const status = exception.getStatus();
// Log the exception this.logger.error( `HTTP Exception: ${exception.message}`, exception.stack, `${request.method} ${request.url}` );
response.status(status).json({ statusCode: status, timestamp: new Date().toISOString(), path: request.url, message: exception.message, }); }}
3. Environment-specific Error Details
@Catch()export class GlobalExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
const isDevelopment = process.env.NODE_ENV === "development";
const errorResponse = { statusCode: status, timestamp: new Date().toISOString(), path: request.url, message: exception instanceof Error ? exception.message : "Internal server error", // Only include stack trace in development ...(isDevelopment && { stack: exception instanceof Error ? exception.stack : undefined, }), };
response.status(status).json(errorResponse); }}
4. Multiple Filter Precedence
When using multiple filters, remember the order:
- Method-scoped filters
- Controller-scoped filters
- Global filters
// Specific filters are checked first@Catch(BadRequestException)export class BadRequestFilter implements ExceptionFilter { /* ... */}
// General filters are checked last@Catch()export class CatchAllFilter implements ExceptionFilter { /* ... */}
10. Real-world Examples
Example 1: API Rate Limiting Exception
import { HttpException, HttpStatus } from "@nestjs/common";
export class RateLimitException extends HttpException { constructor(limit: number, windowMs: number) { super( { statusCode: HttpStatus.TOO_MANY_REQUESTS, message: "Too many requests", error: "Rate limit exceeded", limit, windowMs, retryAfter: Math.ceil(windowMs / 1000), }, HttpStatus.TOO_MANY_REQUESTS ); }}
// rate-limit.filter.ts@Catch(RateLimitException)export class RateLimitFilter implements ExceptionFilter { catch(exception: RateLimitException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const exceptionResponse = exception.getResponse() as any;
response .status(exception.getStatus()) .header("X-RateLimit-Limit", exceptionResponse.limit) .header("X-RateLimit-Remaining", 0) .header("Retry-After", exceptionResponse.retryAfter) .json(exceptionResponse); }}
Example 2: Database Exception Handler
import { Catch, ArgumentsHost, HttpStatus } from "@nestjs/common";import { BaseExceptionFilter } from "@nestjs/core";
@Catch()export class DatabaseExceptionFilter extends BaseExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { // Handle specific database errors if ( exception instanceof Error && exception.message.includes("duplicate key") ) { const ctx = host.switchToHttp(); const response = ctx.getResponse();
response.status(HttpStatus.CONFLICT).json({ statusCode: HttpStatus.CONFLICT, message: "Resource already exists", error: "Conflict", timestamp: new Date().toISOString(), }); return; }
// Handle connection errors if ( exception instanceof Error && exception.message.includes("connection") ) { const ctx = host.switchToHttp(); const response = ctx.getResponse();
response.status(HttpStatus.SERVICE_UNAVAILABLE).json({ statusCode: HttpStatus.SERVICE_UNAVAILABLE, message: "Database temporarily unavailable", error: "Service Unavailable", timestamp: new Date().toISOString(), }); return; }
// Delegate to base filter for other exceptions super.catch(exception, host); }}
Example 3: Validation Exception Filter
import { Catch, ArgumentsHost } from "@nestjs/common";import { ValidationError } from "class-validator";
@Catch(ValidationError)export class ValidationExceptionFilter implements ExceptionFilter { catch(exception: ValidationError[], host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse();
const errors = exception.map((error) => ({ field: error.property, errors: Object.values(error.constraints || {}), }));
response.status(HttpStatus.BAD_REQUEST).json({ statusCode: HttpStatus.BAD_REQUEST, message: "Validation failed", errors, timestamp: new Date().toISOString(), }); }}
Testing Exception Filters
import { Test } from "@nestjs/testing";import { HttpException, HttpStatus } from "@nestjs/common";import { HttpExceptionFilter } from "./http-exception.filter";
describe("HttpExceptionFilter", () => { let filter: HttpExceptionFilter;
beforeEach(async () => { const module = await Test.createTestingModule({ providers: [HttpExceptionFilter], }).compile();
filter = module.get<HttpExceptionFilter>(HttpExceptionFilter); });
it("should catch HttpException and format response", () => { const mockResponse = { status: jest.fn().mockReturnThis(), json: jest.fn(), }; const mockRequest = { url: "/test", method: "GET", }; const mockHost = { switchToHttp: () => ({ getResponse: () => mockResponse, getRequest: () => mockRequest, }), };
const exception = new HttpException("Test error", HttpStatus.BAD_REQUEST);
filter.catch(exception, mockHost as any);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); expect(mockResponse.json).toHaveBeenCalledWith({ statusCode: HttpStatus.BAD_REQUEST, timestamp: expect.any(String), path: "/test", method: "GET", message: "Test error", }); });});
Summary
Exception filters in NestJS provide a powerful and flexible way to handle errors in your application:
- Built-in exceptions cover most common HTTP errors
- Custom filters allow for specific error handling logic
- Multiple scopes (method, controller, global) provide flexibility
- Catch-all filters handle unexpected errors gracefully
- Inheritance from
BaseExceptionFilter
extends built-in functionality
Remember to:
- Use class-based registration when possible
- Implement proper logging and monitoring
- Consider environment-specific error details
- Test your exception filters thoroughly
- Follow the principle of least privilege in error disclosure
Exception filters are essential for building robust, production-ready NestJS applications that provide consistent and informative error responses to clients while maintaining security and debugging capabilities.
Key Takeaways
- Exception Filters: Use
@Catch()
decorator to handle specific or all exceptions - Built-in Exceptions: NestJS provides comprehensive HTTP exception classes
- Custom Filters: Implement
ExceptionFilter
interface for custom error handling - Global Scope: Register filters globally via
APP_FILTER
token for dependency injection - Multiple Scopes: Apply filters at method, controller, or global level as needed
- Error Responses: Standardize error response format across your application
- Logging: Implement proper error logging for debugging and monitoring