Custom Exception Filters
The built-in exceptions layer is enough for most apps, but eventually you need full control over the response body, headers, or logging when something goes wrong. Exception filters give you that control: they let you intercept a specific class of exception, reach into the underlying request and response objects, and craft a uniform error shape that every client can rely on. This page shows how to implement a filter with @Catch, read context from ArgumentsHost, and bind it where it belongs.
What an exception filter is
An exception filter is a class that implements the ExceptionFilter interface and is decorated with @Catch. When a thrown exception reaches the exceptions layer, Nest looks for a filter whose @Catch metadata matches the exception type. If one is found, Nest hands the exception to the filter’s catch method instead of using the default global filter. Your filter then owns the entire response — you decide the status code, the body, and any side effects such as logging.
The contract is a single method:
catch(exception: T, host: ArgumentsHost): void;
The first parameter is the caught exception. The second, ArgumentsHost, is a wrapper that gives you access to the native arguments passed to the original handler — for an HTTP app that means the underlying request and response objects.
Implementing an HttpException filter
The most common filter catches HttpException and reshapes its body into a consistent envelope. Decorate the class with @Catch(HttpException) so it only fires for that exception type and its subclasses (NotFoundException, BadRequestException, and friends all extend HttpException).
// http-exception.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
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,
method: request.method,
message: exception.getResponse(),
});
}
}
A few things are happening here. host.switchToHttp() narrows the generic ArgumentsHost into an HTTP-specific context. From there getResponse() and getRequest() return the platform objects (Express here, but Fastify works the same way with its own types). exception.getStatus() reads the numeric HTTP status the exception was created with, and getResponse() returns the original message or payload.
Output:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"statusCode": 403,
"timestamp": "2026-06-14T10:22:41.118Z",
"path": "/cats/42",
"method": "GET",
"message": "Forbidden"
}
Every error now carries the same fields — path, method, and timestamp — which makes client-side handling and log correlation dramatically simpler.
Understanding ArgumentsHost
ArgumentsHost exists because Nest runs across multiple transports: HTTP, WebSockets, and microservices (gRPC, Redis, etc.). The raw arguments your handler receives differ per transport, so ArgumentsHost is the neutral wrapper you switch on.
| Method | Returns | Use when |
|---|---|---|
host.switchToHttp() | HttpArgumentsHost | REST / GraphQL HTTP requests |
host.switchToWs() | WsArgumentsHost | WebSocket gateways |
host.switchToRpc() | RpcArgumentsHost | Microservice message handlers |
host.getArgs() | raw any[] | You need the original argument array |
host.getType() | 'http' | 'ws' | 'rpc' | Branching by transport in a shared filter |
Tip: Call
switchToHttp()only inside a filter you know runs on HTTP. If a single filter must serve multiple transports, checkhost.getType()first and branch accordingly, otherwise you will read the wrong objects.
Binding the filter
A filter does nothing until it is bound to a scope. Nest supports three scopes, from narrowest to widest.
Bind at the method level for a single route with the @UseFilters decorator:
// cats.controller.ts
import { Controller, Get, UseFilters, ForbiddenException } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';
@Controller('cats')
export class CatsController {
@Get(':id')
@UseFilters(HttpExceptionFilter)
findOne() {
throw new ForbiddenException();
}
}
Passing the class (rather than an instance) lets Nest instantiate the filter through the DI container, so it can inject dependencies and reuse a single instance. Apply @UseFilters to the controller class itself to cover every route in that controller.
To bind globally — covering the entire application — register it in main.ts:
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './http-exception.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();
Filters bound this way with useGlobalFilters cannot inject dependencies because they live outside any module. If your filter needs a logger or config service, register it as a provider with the APP_FILTER token instead — see the global exception filter page for that pattern.
Best Practices
- Scope
@Catchto the narrowest exception type you actually handle so unrelated errors still fall through to other filters. - Always read status and body via
exception.getStatus()andexception.getResponse()rather than hardcoding values. - Use
host.switchToHttp()to get the platform response, and prefer the typedgetResponse<Response>()form for safety. - Keep one consistent error envelope (status, message, path, timestamp) across the whole API so clients parse errors uniformly.
- Pass the filter class to
@UseFilters(not an instance) so Nest can resolve it through DI and reuse it. - For global filters that need injected services, register via
APP_FILTERinstead ofuseGlobalFilters(new Filter()).