title: 'Exception Filters · Nael Framework' description: 'Learn how to use exception filters for centralized error handling and custom error responses in Nael applications.'
Exception Filters
Exception filters let you control the exact flow of error handling and format error responses sent to the client. While middleware can catch errors, exception filters provide a more structured approach with access to the request context and the ability to chain multiple filters.
Creating an exception filter
Exception filters implement the ExceptionFilter interface, which requires a single catch() method.
This method receives the exception and the request context, and must return a Response object.
import { ExceptionFilter, HttpException } from '@nl-framework/http';import type { RequestContext } from '@nl-framework/http';export class HttpExceptionFilter implements ExceptionFilter { async catch(exception: Error, context: RequestContext): Promise<Response> { if (exception instanceof HttpException) { return new Response( JSON.stringify({ statusCode: exception.status, message: exception.message, timestamp: new Date().toISOString(), path: new URL(context.request.url).pathname, }), { status: exception.status, headers: { 'Content-Type': 'application/json' }, } ); } // Handle other exceptions return new Response( JSON.stringify({ statusCode: 500, message: 'Internal Server Error', timestamp: new Date().toISOString(), }), { status: 500, headers: { 'Content-Type': 'application/json' }, } ); }}Registering exception filters
Exception filters are registered globally using the registerExceptionFilters() function, typically during
application bootstrap:
import { registerExceptionFilters } from '@nl-framework/http';// Register during application bootstrapregisterExceptionFilters(new HttpExceptionFilter());Filters are executed in the order they are registered. If a filter successfully handles an exception (returns a Response), subsequent filters are not called. If a filter throws or re-throws the exception, the next filter in the chain is tried.
HTTP exceptions
The HttpException class provides a convenient way to throw HTTP errors with specific status codes.
It includes static factory methods for common HTTP error statuses:
import { HttpException } from '@nl-framework/http';// 400 Bad Requestthrow HttpException.badRequest('Invalid input');// 401 Unauthorizedthrow HttpException.unauthorized('Authentication required');// 403 Forbiddenthrow HttpException.forbidden('Access denied');// 404 Not Foundthrow HttpException.notFound('Resource not found');// 409 Conflictthrow HttpException.conflict('Resource already exists');// 422 Unprocessable Entitythrow HttpException.unprocessableEntity('Validation failed');// 500 Internal Server Errorthrow HttpException.internalServerError('Something went wrong');// 503 Service Unavailablethrow HttpException.serviceUnavailable('Service temporarily unavailable');// Custom status codethrow new HttpException(418, "I'm a teapot");Use HttpException in your controllers and services to indicate HTTP-level errors:
import { HttpException } from '@nl-framework/http';import { Controller, Get, Post, Body, Param } from '@nl-framework/http';@Controller('/posts')export class PostsController { @Get('/:id') async findOne(@Param('id') id: string) { const post = await this.findPostById(id); if (!post) { throw HttpException.notFound(`Post with ID ${id} not found`); } return post; } @Post('/') async create(@Body() data: CreatePostDto) { if (!data.title || data.title.length < 3) { throw HttpException.badRequest('Title must be at least 3 characters'); } const existingPost = await this.findByTitle(data.title); if (existingPost) { throw HttpException.conflict('Post with this title already exists'); } return await this.createPost(data); }}Validation exception filter
Create specialized filters for specific exception types. Here's a filter that handles validation errors:
import { ExceptionFilter } from '@nl-framework/http';import { ValidationException } from '@nl-framework/core';import type { RequestContext } from '@nl-framework/http';export class ValidationExceptionFilter implements ExceptionFilter { async catch(exception: Error, context: RequestContext): Promise<Response> { if (exception instanceof ValidationException) { return new Response( JSON.stringify({ statusCode: 400, message: 'Validation failed', errors: exception.issues.map((issue) => ({ field: issue.path.join('.'), message: issue.message, })), }), { status: 400, headers: { 'Content-Type': 'application/json' }, } ); } // Let other filters handle this throw exception; }}Note that this filter re-throws the exception if it's not a ValidationException, allowing other
filters in the chain to handle it.
RFC 7807 Problem Details
import { ExceptionFilter, HttpException } from '@nl-framework/http';import type { RequestContext } from '@nl-framework/http';export class ProblemJsonFilter implements ExceptionFilter { async catch(exception: Error, context: RequestContext): Promise<Response> { const url = new URL(context.request.url); if (exception instanceof HttpException) { return new Response( JSON.stringify({ type: `/errors/${exception.status}`, title: this.getStatusText(exception.status), status: exception.status, detail: exception.message, instance: url.pathname, }), { status: exception.status, headers: { 'Content-Type': 'application/problem+json' }, } ); } return new Response( JSON.stringify({ type: '/errors/500', title: 'Internal Server Error', status: 500, detail: exception.message, instance: url.pathname, }), { status: 500, headers: { 'Content-Type': 'application/problem+json' }, } ); } private getStatusText(status: number): string { const statusTexts: Record<number, string> = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 422: 'Unprocessable Entity', 500: 'Internal Server Error', 503: 'Service Unavailable', }; return statusTexts[status] || 'Error'; }}Logging exceptions
Exception filters can integrate with the logging system to track errors:
import { ExceptionFilter, HttpException } from '@nl-framework/http';import { Logger, LoggerFactory } from '@nl-framework/logger';import type { RequestContext } from '@nl-framework/http';export class LoggingExceptionFilter implements ExceptionFilter { private readonly logger: Logger; constructor(loggerFactory: LoggerFactory) { this.logger = loggerFactory.create({ context: 'ExceptionFilter' }); } async catch(exception: Error, context: RequestContext): Promise<Response> { const url = new URL(context.request.url); const status = exception instanceof HttpException ? exception.status : 500; this.logger.error('Request exception', exception, { method: context.request.method, path: url.pathname, status, }); if (exception instanceof HttpException) { return new Response( JSON.stringify({ statusCode: exception.status, message: exception.message, }), { status: exception.status, headers: { 'Content-Type': 'application/json' }, } ); } return new Response( JSON.stringify({ statusCode: 500, message: 'Internal Server Error', }), { status: 500, headers: { 'Content-Type': 'application/json' }, } ); }}Multiple filters
Register multiple exception filters to handle different types of errors. Filters are tried in order until one successfully handles the exception:
import { registerExceptionFilters } from '@nl-framework/http';import { NaelFactory } from '@nl-framework/platform';import { LoggerFactory } from '@nl-framework/logger';const bootstrap = async () => { const app = await NaelFactory.create(AppModule); const loggerFactory = await app.get(LoggerFactory); // Register filters in order // First filter catches ValidationException // Second filter catches HttpException // Third filter catches everything else and logs registerExceptionFilters( new ValidationExceptionFilter(), new HttpExceptionFilter(), new LoggingExceptionFilter(loggerFactory), ); await app.listen({ http: 3000 });};bootstrap().catch(console.error);Tip: Register more specific filters first (like ValidationExceptionFilter),
followed by more general filters. The last filter should be a catch-all that handles any unhandled exceptions.
Custom exceptions
Create custom exception types and corresponding filters for domain-specific errors:
export class DatabaseException extends Error { constructor( message: string, public readonly query?: string, public readonly cause?: Error, ) { super(message); this.name = 'DatabaseException'; }}export class DatabaseExceptionFilter implements ExceptionFilter { async catch(exception: Error, context: RequestContext): Promise<Response> { if (exception instanceof DatabaseException) { console.error('Database error:', { message: exception.message, query: exception.query, cause: exception.cause, }); return new Response( JSON.stringify({ statusCode: 500, message: 'Database operation failed', }), { status: 500, headers: { 'Content-Type': 'application/json' }, } ); } // Let other filters handle this throw exception; }}Catch-all filter
Always register a catch-all filter as the last filter to handle any unexpected errors:
import { ExceptionFilter } from '@nl-framework/http';import type { RequestContext } from '@nl-framework/http';export class CatchAllFilter implements ExceptionFilter { async catch(exception: Error, context: RequestContext): Promise<Response> { // This catches everything that previous filters didn't handle console.error('Unhandled exception:', exception); // In production, don't expose internal error details const isDevelopment = process.env.NODE_ENV === 'development'; return new Response( JSON.stringify({ statusCode: 500, message: 'Internal Server Error', ...(isDevelopment && { error: exception.message, stack: exception.stack }), }), { status: 500, headers: { 'Content-Type': 'application/json' }, } ); }}Security Warning: Never expose internal error details (stack traces, database queries, etc.) in production environments. Only include detailed error information in development mode.
Filter execution order
Understanding how exception filters are executed is crucial for proper error handling:
- An exception is thrown in a controller, service, guard, or middleware
- The router catches the exception
- Exception filters are tried in registration order
If a filter returns a
Response, that response is sent to the client- If a filter throws or re-throws, the next filter is tried
- If no filter handles the exception, a default 500 error response is returned
Best practices
Use specific exception types - Create custom exceptions for different error scenarios
Order matters - Register specific filters before general ones
Always have a catch-all - Ensure all exceptions are handled gracefully
Log errors - Track exceptions for debugging and monitoring
Don't leak sensitive data - Never expose internal details in production
Return consistent formats - Use a standard error response structure
Include request context - Add timestamps, request IDs, and paths to error responses