Exception Filters & Error Handling
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2), Node.js 20.19+ or 22.12+, TypeScript 6 (6.0.x), Express 5.2 and Fastify 5.12,
all checked on 2026-10-02. It covers everything that happens to a request after something goes wrong: the built-in
exceptions layer, HttpException and its subclasses, the exception filters that let you replace the response, how
filters are bound and in which order they are tried, and how to map your own domain errors to HTTP status codes without
leaking implementation details. Every snippet on this page compiles with TypeScript 6 in strict mode and behaves the same on the Express and the Fastify
adapter (checked with end-to-end tests using Vitest and supertest), and the JSON shown is what a client receives. The examples use the Bookshelf scenario that grows through the section; the controllers
and providers they plug into are introduced in
Controllers & Routing and
Providers & Dependency Injection.
The built-in exceptions layer
Nest ships with an exceptions layer that processes every exception your application does not handle itself. When a route handler (or a guard, pipe, interceptor or service it calls) throws and nothing catches the error, the layer catches it and sends a response. Out of the box this is a built-in global exception filter:
-
An
HttpException, or a class that inherits from it, is serialized with the status code and body it carries. -
Anything else (a
TypeError, a database driver error, a thrown string) is unrecognized. The client receives a generic500and the details stay on the server.
{
"statusCode": 500,
"message": "Internal server error"
}
Source: NestJS: Exception filters
The generic body is deliberate: an error message such as database password leaked in message (the text used by the
test route here) never reaches the client, while the original error is logged with its stack trace by the
ExceptionsHandler logger context. That is the baseline every custom filter on this page has to preserve.
(middleware, guard, interceptor, pipe, handler, service)"] --> C{"Custom filter
matches the exception?"} C -- "yes: first match wins" --> F["Custom filter.catch(exception, host)
writes the response"] C -- "no" --> B{"HttpException?"} B -- "yes" --> H["Built-in filter replies with
the status and body it carries"] B -- "no" --> U{"http-errors / Fastify error shape?"} U -- "yes" --> HE["Reply with statusCode + message"] U -- "no" --> E["Reply 500 Internal server error
and log the original error"]
Source: NestJS: Exception filters
Where filters sit in the request lifecycle
Filters run only when an uncaught exception occurs. As soon as one is thrown, the rest of the lifecycle is skipped and
the request goes straight to the exception filter, which sits at the very end of the chain, after interceptors have had a chance to
see the error. Exceptions you catch yourself with try/catch never reach a filter. Two consequences matter:
-
Exceptions thrown by guards, pipes and interceptors are handled by filters exactly like exceptions thrown in a controller, so a
ForbiddenExceptionfrom a guard or theBadRequestExceptionproduced by a validation pipe is shaped by your filters. See Guards and Pipes & Validation. -
Exceptions thrown by middleware are processed by the exceptions layer too, but middleware runs before a route handler is selected, so only global filters apply (
app.useGlobalFilters()or anAPP_FILTERprovider). Method-scoped and controller-scoped@UseFilters()bindings are not invoked. See Middleware.
Interceptors can also catch and transform errors with RxJS catchError before they reach a filter; see
Interceptors. The complete ordering of every enhancer is in
Request Lifecycle.
Source: NestJS: Request lifecycle
HttpException
HttpException (from @nestjs/common) is the base class for everything that should become an HTTP error response.
Its constructor takes two required arguments and one optional one:
| Argument | Meaning |
|---|---|
|
The response body: a |
|
The HTTP status code. Use the |
|
An object with |
With a string, the default body has two properties: statusCode (the status you passed) and message:
import { Controller, Get, HttpException, HttpStatus } from '@nestjs/common';
@Controller('demo')
export class DemoController {
@Get('forbidden')
forbidden(): never {
throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}
}
curl -i http://localhost:3000/demo/forbidden
{ "statusCode": 403, "message": "Forbidden" }
Passing an object replaces the entire body. Nest does not add statusCode or message for you, so you own the
whole contract. The third argument carries the error cause: the original error that made you throw. It is never
serialized into the response, which makes it the right place for diagnostic detail:
@Get('custom-body')
customBody(): never {
try {
JSON.parse('{oops');
} catch (error) {
throw new HttpException(
{ status: HttpStatus.FORBIDDEN, error: 'This is a custom message' },
HttpStatus.FORBIDDEN,
{ cause: error },
);
}
}
{ "status": 403, "error": "This is a custom message" }
The exception exposes its parts through getStatus(), getResponse() and the standard cause property, which is
what filters use to build their own output.
cause, description and errorCode
All built-in exceptions and HttpException accept the same options object. cause is for logs, description replaces the
short error name in the default body, and errorCode is a stable, machine-readable identifier that is serialized
into the response:
import { BadRequestException } from '@nestjs/common';
@Get('options')
options(): never {
throw new BadRequestException('Something bad happened', {
cause: new Error('inner'),
description: 'Some error description',
});
}
{
"message": "Something bad happened",
"error": "Some error description",
"statusCode": 400
}
The cause is absent from the body, and description took the place of the default "error": "Bad Request".
errorCode solves a problem that status and message cannot: several distinct failures (an invalid email, a weak
password) can all be 400 Bad Request, which forces a client to parse the human-readable message to tell them apart.
It is optional, can be combined with cause and description, and is also available on HttpException itself, so
custom exceptions can set it:
@Get('error-code')
errorCode(): never {
throw new BadRequestException('Password is too weak', { errorCode: 'WEAK_PASSWORD' });
}
@Get('error-code-http')
errorCodeHttp(): never {
throw new HttpException('Forbidden', HttpStatus.FORBIDDEN, { errorCode: 'ACCOUNT_SUSPENDED' });
}
{
"message": "Password is too weak",
"error": "Bad Request",
"statusCode": 400,
"errorCode": "WEAK_PASSWORD"
}
{ "statusCode": 403, "message": "Forbidden", "errorCode": "ACCOUNT_SUSPENDED" }
Unlike cause, which is meant for logging and never serialized, errorCode is part of the response body and is meant
for clients. The built-in filter adds it for you in the default body only: if you pass an object as the response
argument, or write your own filter, you decide whether and how errorCode is emitted (read it from exception.errorCode). The
documentation example for BadRequestException omits the "error": "Bad Request" property that the real response
contains alongside a custom message; the tests behind this page show the output above.
Custom exceptions
You rarely need your own exception classes, but when you do, build a small hierarchy that extends HttpException so the
built-in handler recognizes it:
import { HttpException, HttpStatus } from '@nestjs/common';
export class ForbiddenException extends HttpException {
constructor() {
super('Forbidden', HttpStatus.FORBIDDEN);
}
}
Because it extends HttpException, throwing it from a handler needs no filter at all. Note that Nest already exports a
ForbiddenException; the snippet is the documentation’s own illustration, and in a real project you would extend a
built-in class instead (class IsbnTakenException extends ConflictException). The trade-off of this approach is
coupling: an exception that carries an HTTP status belongs to the transport layer, and a service that throws it cannot
be reused from a microservice or a queue worker without translation. The section
Mapping domain errors to HTTP shows the alternative.
Built-in HTTP exceptions
Nest provides a set of standard exceptions that inherit from HttpException, all exported from @nestjs/common.
Every one accepts a message (or response object) and the options described above, and none of them needs a status
argument. The status codes below are the values the installed 12.1 classes report.
| Class | Status | Typical use |
|---|---|---|
|
400 |
Malformed input that failed validation |
|
401 |
Missing or invalid credentials |
|
403 |
Authenticated, but not allowed |
|
404 |
The addressed resource does not exist |
|
405 |
Verb not supported for the route |
|
406 |
Cannot satisfy the |
|
408 |
The request took too long |
|
409 |
State conflict such as a duplicate key |
|
410 |
The resource existed and is permanently removed |
|
412 |
A conditional-request precondition failed |
|
413 |
Body larger than the server accepts |
|
415 |
Unsupported |
|
418 |
The April Fools' status; occasionally used for "refuse politely" |
|
421 |
Request sent to a server that cannot answer it |
|
422 |
Well-formed but semantically invalid |
|
500 |
An unexpected server fault |
|
501 |
Capability not implemented |
|
502 |
An upstream returned an invalid response |
|
503 |
Temporarily overloaded or down |
|
504 |
An upstream did not answer in time |
|
505 |
Unsupported HTTP version |
The default body differs slightly depending on how you construct the exception. With no arguments it contains only
message and statusCode; with a message it adds error (the status name) between them:
{ "message": "Not Found", "statusCode": 404 }
{ "message": "Book 978-9 not found", "error": "Not Found", "statusCode": 404 }
A request to a route that does not exist produces the second shape, {"message":"Cannot GET /nope","error":"Not Found","statusCode":404}.
That is a NotFoundException raised by the platform’s router, so it passes through your filters too, and a catch-everything
filter therefore also formats unknown-route errors.
Unrecognized exceptions
Everything that is not an HttpException is unrecognized. The built-in filter replies 500 Internal server error and
logs the original error, with three refinements worth knowing:
-
http-errors shape. An error carrying the signature of the
http-errorslibrary (a booleanexposeand equalstatusandstatusCodenumbers on anError) is sent back with its ownstatusCodeandmessage. Express middleware such as the JSON body parser produces such errors, which is why malformed JSON in a request body yields a clean400and not a500. Fastify’s own errors (those with a stringcodeand a numericstatusCode) are treated the same way, and so are plain objects (notErrorinstances) that carry astatusCodebetween 400 and 599 plus a non-emptymessage. -
Anything can be thrown. JavaScript allows throwing a string or a plain object. The built-in filter handles them (the client sees the generic
500), but your own filters must not assumeexception instanceof Error. -
Headers already sent. If the response has started streaming when the error arrives, the built-in filter ends the response instead of trying to write a new body.
@Get('http-errors')
httpErrorsShape(): never {
// The shape produced by the "http-errors" library, used by Express middleware such as body-parser.
throw Object.assign(new Error('Teapot says no'), { expose: true, status: 418, statusCode: 418 });
}
Source: NestJS: Exception filters
{ "statusCode": 418, "message": "Teapot says no" }
A malformed body such as {bad posted to a JSON endpoint on Express returns
{"message":"Expected property name or '}' in JSON at position 1 (line 1 column 2)","error":"Bad Request","statusCode":400}.
Logging and IntrinsicException
By default the built-in filter does not log HttpException and its subclasses: they are treated as part of the
normal application flow (a 404 is not an incident), so they do not appear in the console. The same applies to
WsException and RpcException in their contexts. They are exempt because HttpException inherits from
IntrinsicException, a class exported from @nestjs/common, and the built-in filters never log instances of it. Any
error that is not intrinsic is logged with its stack under the ExceptionsHandler context.
You can use the same marker for your own expected failures: extend IntrinsicException and the built-in filter will answer
(with the generic 500, since it is not an HttpException) without writing a log entry:
import { IntrinsicException } from '@nestjs/common';
export class QuietError extends IntrinsicException {}
Test results: throwing QuietError produced the 500 body and zero log calls, while an ordinary Error produced a logged stack trace.
Use it sparingly: the practical use is a custom filter that translates the error to a proper status anyway, so the default logger stays quiet for
failures that you consider normal. To log intrinsic exceptions, bind a filter that does it, as in the following example. It
extends BaseExceptionFilter (see Extending BaseExceptionFilter), writes the cause that is never serialized, and keeps the default response:
import { ArgumentsHost, Catch, HttpException, Logger } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
@Catch(HttpException)
export class LoggingHttpExceptionFilter extends BaseExceptionFilter {
private readonly logger = new Logger(LoggingHttpExceptionFilter.name);
override catch(exception: HttpException, host: ArgumentsHost) {
const type = host.getType<'http' | 'rpc' | 'ws'>();
if (exception.getStatus() >= 500 || exception.cause) {
this.logger.error(
`[${type}] ${exception.message}`,
exception.cause instanceof Error ? exception.cause.stack : undefined,
);
}
super.catch(exception, host);
}
}
An exception filter decides what the client sees; it does not tell you that a TypeError has started firing on four percent
of checkouts. Production error tracking (grouping, source-mapped stack traces, release correlation) is a separate concern
handled by instrumentation such as NestJS Observe, covered with the rest of observability in the later pages of this section, and it
complements filters rather than replacing them.
Exception filters
When you need full control over the exceptions layer (to add logging, to change the JSON schema, to translate errors from another layer) you write an exception filter. A filter is a class that:
-
is decorated with
@Catch(), which lists the exception classes it handles, -
implements
ExceptionFilter<T>from@nestjs/common, whose single method iscatch(exception: T, host: ArgumentsHost).
The following filter catches HttpException and replaces the body with the status, a timestamp and the request path. It
takes the platform Request and Response objects from the host:
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import type { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
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,
});
}
}
Source: NestJS: Exception filters
{ "statusCode": 403, "timestamp": "2026-10-02T15:30:20.051Z", "path": "/demo/method-filter" }
@Catch() accepts a single class or a comma-separated list, so one filter can serve several exception types
(@Catch(BookNotFoundError, IsbnTakenError)). With an empty list it catches everything; see Catch-everything filters.
Express and Fastify
The filter above uses the Express Response. On the Fastify adapter the reply object is different: there is no
json(), and you use send() instead, with types imported from fastify:
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
@Catch(HttpException)
export class FastifyHttpExceptionFilter implements ExceptionFilter<HttpException> {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const reply = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const status = exception.getStatus();
reply.status(status).send({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
Source: NestJS: Exception filters
Writing against the platform object ties the filter to one adapter. The portable alternative, shown in
Catch-everything filters, delivers the reply through the HTTP adapter (httpAdapter.reply()), and the same class then works
on both: the Express filter works on Express only, the adapter-based filters on Express and Fastify.
ArgumentsHost
The host parameter is an ArgumentsHost, a wrapper around the arguments that were passed to the original handler.
Those arguments differ by transport (Express passes (req, res, next), a gateway passes the socket and data, a
microservice handler passes the payload and context), so ArgumentsHost offers uniform accessors:
| Member | Use |
|---|---|
|
The application type as a string: |
|
A view exposing |
|
The equivalent views for microservice ( |
|
The raw argument array of the handler, for contexts without a dedicated view. |
Source: NestJS: Execution context
ArgumentsHost is the base of ExecutionContext, which adds getClass() and getHandler() for guards and
interceptors. Filters get only the narrower host, because by the time they run the handler has failed.
Binding filters and scope
A filter does nothing until it is bound. There are four levels, from narrowest to widest.
Method scope. @UseFilters() accepts one filter or a comma-separated list. Pass the class rather than an instance
when you can: Nest instantiates it, enables dependency injection and reuses the same instance across the module, which
reduces memory use. Passing an instance (new HttpExceptionFilter()) works but bypasses injection:
import { Controller, ForbiddenException, Get, UseFilters } from '@nestjs/common';
@Controller('demo')
export class DemoController {
@Get('method-filter')
@UseFilters(HttpExceptionFilter)
methodFilter(): never {
throw new ForbiddenException();
}
}
Controller scope. Decorate the class and every handler in it is covered:
@Controller('scoped')
@UseFilters(new HttpExceptionFilter())
export class ScopedController {
@Get()
get(): never {
throw new ForbiddenException();
}
}
Global scope with useGlobalFilters(). Bind the filter in main.ts. A filter registered this way is created outside
any module, so it cannot inject dependencies, and it does not apply to gateways or hybrid applications (bind WebSocket
and microservice filters on the gateway or controller instead):
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { HttpExceptionFilter } from './http-exception.filter.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
Global scope with APP_FILTER. To give a global filter its dependencies, register it as a provider with the APP_FILTER token
from any module. It is still global regardless of the module that declares it, so register it in the module that defines
the filter. useClass is not the only way to register a custom provider; see
Providers & Dependency Injection. You can register as
many filters as you need by adding each as a provider:
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { ProblemDetailsFilter } from './problem-details.filter.js';
@Module({
providers: [{ provide: APP_FILTER, useClass: ProblemDetailsFilter }],
})
export class AppModule {}
For the module mechanics behind this (why a provider with that token is global), see Modules.
| Binding | Scope | Dependency injection | Applies to middleware errors |
|---|---|---|---|
|
One handler |
Yes (class form) |
No |
|
Every handler of the controller |
Yes (class form) |
No |
|
Whole HTTP application |
No |
Yes |
|
Whole application |
Yes |
Yes |
Which filter handles an exception
Only one filter handles each exception. Nest builds the candidate list for a handler as global filters, then
controller filters, then method filters, reverses it, and takes the first filter whose @Catch() list matches the
exception (an instanceof check, or an empty list matching everything). The effect is:
-
A method-scoped filter is tried before a controller-scoped one, which is tried before a global one.
-
Inside one level, the filter declared last is tried first.
-
There is no fall-through: if the first matching filter does not write a response, nobody else does. A filter that wants to defer to the default behaviour must extend
BaseExceptionFilterand callsuper.catch()(Extending BaseExceptionFilter).
This is why combining a catch-everything filter with a specific one requires care: declare the catch-everything filter first, so
the specific filter is checked before it. The two orders were tested with BookNotFoundError thrown from a handler,
and the results differ:
// Catch-everything first, specific filter second: the specific filter wins for BookNotFoundError.
providers: [
{ provide: APP_FILTER, useClass: CatchEverythingFilter },
{ provide: APP_FILTER, useClass: ProblemDetailsFilter },
],
{ "type": "https://bookshelf.example/problems/book-not-found", "title": "Book not found", "status": 404, "detail": "Book zzz not found", "instance": "/books/zzz" }
Reversing the two entries makes the catch-everything filter match first, and the client receives
{"statusCode":500,"timestamp":"…","path":"/books/zzz"} instead. The same holds when instances are passed to
app.useGlobalFilters(catchAll, specific).
Source: NestJS: Request lifecycle
Catch-everything filters
Leaving @Catch() empty makes a filter handle every exception, whatever its type. A platform-agnostic version delivers
the response through the HTTP adapter instead of the Express or Fastify objects, so one class serves both. It gets the
adapter from HttpAdapterHost:
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
@Catch()
export class CatchEverythingFilter implements ExceptionFilter {
private readonly logger = new Logger(CatchEverythingFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
// `httpAdapter` might not be available in the constructor, so resolve it here.
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const isHttp = exception instanceof HttpException;
const httpStatus = isHttp ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
if (!isHttp) {
// Unknown errors are logged with their stack; the client only sees a generic body.
this.logger.error(exception instanceof Error ? exception.stack : String(exception));
}
const responseBody = {
statusCode: httpStatus,
timestamp: new Date().toISOString(),
path: httpAdapter.getRequestUrl(ctx.getRequest()),
};
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
}
}
curl -s http://localhost:3000/demo/boom
curl -s http://localhost:3000/nope
{ "statusCode": 500, "timestamp": "2026-10-02T15:30:25.367Z", "path": "/demo/boom" }
{ "statusCode": 404, "timestamp": "2026-10-02T15:30:25.367Z", "path": "/nope" }
The response for /demo/boom does not contain the thrown message (the string leaked never appears), and the
same three requests produced identical results on Express and on Fastify. Points to keep in mind:
-
Why
HttpAdapterHost. The adapter (httpAdapter) may not be available while the filter is constructed, so read it insidecatch(). The adapter exposesreply(response, body, status),getRequestUrl(request),setHeader(response, name, value)andisHeadersSent(response). -
Logging. A catch-everything filter replaces the built-in one, and with it the built-in logging of unknown errors, so log them yourself as above; otherwise 500s disappear silently.
-
Preserve the status. Always check
instanceof HttpExceptionfirst; mapping everything to500would turn every404and400into a server fault. -
Do not leak. Return a generic message for unrecognized errors; stack traces and driver messages belong in logs.
Extending BaseExceptionFilter
Sometimes you want the default behaviour with one addition, such as logging or a metric. Extend BaseExceptionFilter
from @nestjs/core and delegate with super.catch():
import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
override catch(exception: unknown, host: ArgumentsHost) {
this.logger.warn(`Handled: ${exception instanceof Error ? exception.message : String(exception)}`);
super.catch(exception, host);
}
}
Method-scoped and controller-scoped filters that extend BaseExceptionFilter must not be instantiated with new; let
the framework create them (@UseFilters(AllExceptionsFilter)), because the base class needs the HTTP adapter injected.
Global filters can extend it in two ways. Register the filter through the APP_FILTER token (the adapter is injected for
you), or pass the adapter to the constructor yourself:
import { NestFactory, HttpAdapterHost } from '@nestjs/core';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const { httpAdapter } = app.get(HttpAdapterHost);
app.useGlobalFilters(new AllExceptionsFilter(httpAdapter));
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
Both forms behave the same: a 403 through the APP_FILTER registration keeps its default {"statusCode":403,"message":"Forbidden"} body
while the handled warning is logged, and an unknown error through the manually constructed global filter still produces the generic 500.
Mapping domain errors to HTTP
Business rules fail in business terms: a book is missing, an ISBN is taken, there are no copies left. Services that
express this with HttpException subclasses are coupled to HTTP, so a worker that reuses BooksService has to
understand status codes. A cleaner design keeps domain errors plain and translates them at the edge.
Start with errors that know nothing about Nest:
export class BookNotFoundError extends Error {
constructor(readonly isbn: string) {
super(`Book ${isbn} not found`);
this.name = 'BookNotFoundError';
}
}
export class IsbnTakenError extends Error {
constructor(readonly isbn: string) {
super(`ISBN ${isbn} is already registered`);
this.name = 'IsbnTakenError';
}
}
export class OutOfStockError extends Error {
constructor(readonly isbn: string) {
super(`Book ${isbn} is out of stock`);
this.name = 'OutOfStockError';
}
}
The service throws them and the controller stays a thin pass-through:
import { Injectable } from '@nestjs/common';
export interface Book {
isbn: string;
title: string;
stock: number;
}
@Injectable()
export class BooksService {
private readonly books = new Map<string, Book>([
['978-0', { isbn: '978-0', title: 'Nest in Action', stock: 3 }],
['978-1', { isbn: '978-1', title: 'Sold Out', stock: 0 }],
]);
findOne(isbn: string): Book {
const book = this.books.get(isbn);
if (!book) throw new BookNotFoundError(isbn);
return book;
}
create(book: Book): Book {
if (this.books.has(book.isbn)) throw new IsbnTakenError(book.isbn);
this.books.set(book.isbn, book);
return book;
}
borrow(isbn: string): Book {
const book = this.findOne(isbn);
if (book.stock === 0) throw new OutOfStockError(isbn);
book.stock -= 1;
return book;
}
}
Source: NestJS: Providers
@Controller('books')
export class BooksController {
constructor(private readonly books: BooksService) {}
@Get(':isbn')
findOne(@Param('isbn') isbn: string): Book {
return this.books.findOne(isbn);
}
@Post()
create(@Body() body: Book): Book {
return this.books.create(body);
}
@Post(':isbn/borrow')
borrow(@Param('isbn') isbn: string): Book {
return this.books.borrow(isbn);
}
}
Source: NestJS: Controllers
Without any filter, an unmapped domain error is a server fault: GET /books/zzz returns the generic 500 and logs a
stack trace, which is the correct signal that the mapping is missing. A single filter closes the gap.
Three strategies exist, and they compose:
| Strategy | Pros | Cons |
|---|---|---|
Throw |
No extra code; the built-in filter handles it |
The domain layer depends on HTTP; unusable as-is from queues or microservices |
Catch in the controller and rethrow an |
Explicit per route; keeps the service pure |
Repetitive; easy to forget on one route |
A filter that maps domain error classes to HTTP responses (below) |
One table of mappings; services and controllers stay clean; reusable |
Needs registration; unmapped errors still become |
RFC 9457 problem details
RFC 9457 defines a standard JSON error format, application/problem+json, so that clients do not need a bespoke parser per API.
Its members are type (a URI identifying the problem kind), title (a short, stable summary), status, detail
(an explanation specific to this occurrence) and instance (identifying this occurrence, often the request path);
you may add extension members, which is where a machine-readable code such as the errorCode above fits. The filter below combines the
two ideas: it maps each domain error class to a status and a problem type, and it replies with the adapter, so it works
on Express and Fastify without change:
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
type DomainError = BookNotFoundError | IsbnTakenError | OutOfStockError;
const mapping = new Map<string, { status: HttpStatus; type: string; title: string }>([
['BookNotFoundError', { status: HttpStatus.NOT_FOUND, type: 'https://bookshelf.example/problems/book-not-found', title: 'Book not found' }],
['IsbnTakenError', { status: HttpStatus.CONFLICT, type: 'https://bookshelf.example/problems/isbn-taken', title: 'ISBN already registered' }],
['OutOfStockError', { status: HttpStatus.CONFLICT, type: 'https://bookshelf.example/problems/out-of-stock', title: 'Out of stock' }],
]);
// RFC 9457 problem details for domain errors, platform-agnostic via the HTTP adapter.
@Catch(BookNotFoundError, IsbnTakenError, OutOfStockError)
export class ProblemDetailsFilter implements ExceptionFilter<DomainError> {
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: DomainError, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const { status, type, title } = mapping.get(exception.name)!;
httpAdapter.setHeader(ctx.getResponse(), 'Content-Type', 'application/problem+json');
httpAdapter.reply(
ctx.getResponse(),
{
type,
title,
status,
detail: exception.message,
instance: httpAdapter.getRequestUrl(ctx.getRequest()),
},
status,
);
}
}
Register it with APP_FILTER so that it can inject HttpAdapterHost, and exercise it:
@Module({
controllers: [BooksController],
providers: [BooksService, { provide: APP_FILTER, useClass: ProblemDetailsFilter }],
})
export class BooksModule {}
curl -i http://localhost:3000/books/zzz
curl -i -X POST http://localhost:3000/books/978-1/borrow
{
"type": "https://bookshelf.example/problems/book-not-found",
"title": "Book not found",
"status": 404,
"detail": "Book zzz not found",
"instance": "/books/zzz"
}
{
"type": "https://bookshelf.example/problems/out-of-stock",
"title": "Out of stock",
"status": 409,
"detail": "Book 978-1 is out of stock",
"instance": "/books/978-1/borrow"
}
Both responses carry Content-Type: application/problem+json; charset=utf-8, the HttpException`s thrown elsewhere keep their
default shape (this filter does not match them), and the successful `POST /books/978-0/borrow is unaffected. The Map lookup by
exception.name is a convenience; with several packages sharing error names, key the table by class instead.
The detail field comes from the exception message, so never put secrets or internals in a domain error message.
To emit problem details for every failure, including HttpException and unknown errors, convert those inside a
catch-everything filter into the same members (status, title from the HTTP status text, detail from the exception
message, a generic detail for unknown errors) and keep it declared first, as explained in Which filter handles an exception.
Never return errors as 200
A pattern that appears in tutorials, and in the reference book listed in the section bibliography, is catching the error and returning an object with a failure message from the handler:
// Anti-pattern: the client sees HTTP 200 for a failure
@Get(':isbn')
findOne(@Param('isbn') isbn: string) {
try {
return this.books.findOne(isbn);
} catch (error) {
return { success: false, message: (error as Error).message };
}
}
The response is 200 OK, so every layer that relies on status codes (HTTP caches, monitoring dashboards, retry logic in
clients and gateways, OpenAPI-generated clients) believes the call succeeded, and the real failure is only visible by
parsing the body. Throw instead, and let the exceptions layer produce the status:
@Get(':isbn')
findOne(@Param('isbn') isbn: string): Book {
return this.books.findOne(isbn); // BookNotFoundError -> 404 via the filter
}
Source: NestJS: Exception filters
Filters outside HTTP
The same model applies to the other transports, with transport-specific exception and base classes. Their own pages cover them in detail; the key differences are:
| Context | Exception and base filter | Scoping notes |
|---|---|---|
WebSockets |
|
Global filters ( |
Microservices |
|
Method or controller scope with |
GraphQL |
|
Standard Nest filters are compatible with GraphQL; transform the host with |
Testing filters
Filters are best verified end to end, because their value is the HTTP response. Build a testing module with the controller
and the filter provider, and use supertest:
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { BooksModule } from './books.module.js';
describe('problem details', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [BooksModule] }).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(() => app.close());
it('maps BookNotFoundError to a 404 problem', async () => {
const response = await request(app.getHttpServer()).get('/books/zzz');
expect(response.status).toBe(404);
expect(response.headers['content-type']).toContain('application/problem+json');
expect(response.body.instance).toBe('/books/zzz');
});
});
For Fastify, create the application with new FastifyAdapter() and call app.getHttpAdapter().getInstance().ready()
after app.init() before sending requests. A filter registered through app.useGlobalFilters() in main.ts is not
part of the testing module, so repeat the registration in the test or, better, register filters with APP_FILTER so
tests exercise the production wiring.
Pitfalls
| Pitfall | What to do |
|---|---|
Declaring a specific filter before a catch-everything one |
The last-declared filter is tried first, so the catch-everything one swallows the exceptions the specific one was meant for. Declare catch-everything first. |
|
Instances created in |
Expecting a controller-scoped filter to handle errors thrown by middleware |
Middleware errors reach only global filters. |
Expecting global filters to cover gateways or hybrid applications |
Bind WebSocket and microservice filters at gateway, controller or method level. |
Using Express |
Use |
Swallowing errors with a catch-everything filter that only calls |
Log unknown errors yourself, keep the status of |
Catching and returning |
Throw instead; the status code is part of the contract. |
Throwing an exception with an object body and expecting |
An object |
Relying on |
|
Putting secrets or internal data in |
These are returned to the caller. Keep diagnostics in |
Assuming |
Anything can be thrown, so a catch-everything filter must treat its argument as |
What changed recently
-
errorCode(Nest 12).HttpExceptionOptionsaccepts anerrorCode, serialized into the response body for the built-in filter, available onHttpExceptionand all built-in exceptions. -
IntrinsicException.HttpExceptionis an intrinsic exception, so the built-in filters do not log it, and you can mark your own expected errors the same way. -
Nest 12 packages are ESM-only. Import filters with the
.jsextension in an ESM project, as the examples do. -
BaseExceptionFilterknows more error shapes. It recognizeshttp-errors-style errors, Fastify errors and plain status-carrying objects, and it ends the response when headers were already sent (see Unrecognized exceptions).
Source: NestJS: Migration guide
Where the book is outdated
The reference book (see the section bibliography) predates errorCode,
which arrived in Nest 12, so clients had to distinguish same-status failures by parsing messages. It also shows a handler that
catches an error and returns a failure object, which produces 200 OK; as explained in Never return errors as 200, the response must
carry a real status code. If you adopt a standard error format, use RFC 9457, which obsoletes the RFC 7807 that older
material cites.