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 generic 500 and the details stay on the server.

{
  "statusCode": 500,
  "message": "Internal server error"
}

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.

flowchart TD T["Exception thrown
(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"]
Which handler produces the response

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 ForbiddenException from a guard or the BadRequestException produced 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 an APP_FILTER provider). 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

response

The response body: a string, which becomes the message property of a default body, or an object, which Nest serializes as the whole body.

status

The HTTP status code. Use the HttpStatus enum from @nestjs/common instead of magic numbers.

options (optional)

An object with cause, description and, since Nest 12, errorCode.

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

BadRequestException

400

Malformed input that failed validation

UnauthorizedException

401

Missing or invalid credentials

ForbiddenException

403

Authenticated, but not allowed

NotFoundException

404

The addressed resource does not exist

MethodNotAllowedException

405

Verb not supported for the route

NotAcceptableException

406

Cannot satisfy the Accept header

RequestTimeoutException

408

The request took too long

ConflictException

409

State conflict such as a duplicate key

GoneException

410

The resource existed and is permanently removed

PreconditionFailedException

412

A conditional-request precondition failed

PayloadTooLargeException

413

Body larger than the server accepts

UnsupportedMediaTypeException

415

Unsupported Content-Type

ImATeapotException

418

The April Fools' status; occasionally used for "refuse politely"

MisdirectedException

421

Request sent to a server that cannot answer it

UnprocessableEntityException

422

Well-formed but semantically invalid

InternalServerErrorException

500

An unexpected server fault

NotImplementedException

501

Capability not implemented

BadGatewayException

502

An upstream returned an invalid response

ServiceUnavailableException

503

Temporarily overloaded or down

GatewayTimeoutException

504

An upstream did not answer in time

HttpVersionNotSupportedException

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-errors library (a boolean expose and equal status and statusCode numbers on an Error) is sent back with its own statusCode and message. Express middleware such as the JSON body parser produces such errors, which is why malformed JSON in a request body yields a clean 400 and not a 500. Fastify’s own errors (those with a string code and a numeric statusCode) are treated the same way, and so are plain objects (not Error instances) that carry a statusCode between 400 and 599 plus a non-empty message.

  • 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 assume exception 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 });
}
{ "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 is catch(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,
    });
  }
}
{ "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,
    });
  }
}

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

getType()

The application type as a string: 'http', 'rpc' or 'ws' (GraphQL reports 'graphql'). Branch on it in filters shared across transports.

switchToHttp()

A view exposing getRequest(), getResponse() and getNext() for HTTP applications.

switchToRpc(), switchToWs()

The equivalent views for microservice (getData(), getContext()) and WebSocket (getData(), getClient()) contexts.

getArgs(), getArgByIndex(index)

The raw argument array of the handler, for contexts without a dedicated view.

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

@UseFilters() on a method

One handler

Yes (class form)

No

@UseFilters() on a controller

Every handler of the controller

Yes (class form)

No

app.useGlobalFilters(instance)

Whole HTTP application

No

Yes

APP_FILTER provider

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 BaseExceptionFilter and call super.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).

sequenceDiagram participant C as Client participant N as Nest router participant G as Guard / pipe / handler participant X as ExceptionsHandler participant M as Method filters participant K as Controller filters participant Gl as Global filters participant B as Built-in filter C->>N: HTTP request N->>G: run enhancers and handler G-->>X: throws an exception X->>M: first match? (last declared first) alt no match X->>K: first match? end alt no match X->>Gl: first match? end alt no match X->>B: default handling end X-->>C: response written by exactly one filter
Resolution order of exception filters

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 inside catch(). The adapter exposes reply(response, body, status), getRequestUrl(request), setHeader(response, name, value) and isHeadersSent(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 HttpException first; mapping everything to 500 would turn every 404 and 400 into 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;
  }
}
@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);
  }
}

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 HttpException subclasses from services

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 HttpException

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 500 by design

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
}

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

WsException from @nestjs/websockets; BaseWsExceptionFilter, whose constructor options include includeCause and causeFactory

Global filters (useGlobalFilters(), APP_FILTER) do not apply to gateways; bind with @UseFilters() on the gateway or method

Microservices

RpcException from @nestjs/microservices; RpcExceptionFilter (from @nestjs/common) and BaseRpcExceptionFilter (from @nestjs/microservices)

Method or controller scope with @UseFilters()

GraphQL

GqlExceptionFilter and GqlArgumentsHost from @nestjs/graphql, and GraphQLError

Standard Nest filters are compatible with GraphQL; transform the host with GqlArgumentsHost.create(host). See GraphQL for the protocol itself

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.

useGlobalFilters(new MyFilter()) and the filter needs a dependency

Instances created in main.ts are outside any module and receive no injection. Use APP_FILTER.

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 response.json() in an app running on Fastify

Use send() with FastifyReply, or write the filter against the HTTP adapter.

Swallowing errors with a catch-everything filter that only calls reply()

Log unknown errors yourself, keep the status of HttpException, and never put raw messages from unknown errors into the response.

Catching and returning { success: false }

Throw instead; the status code is part of the contract.

Throwing an exception with an object body and expecting statusCode or message

An object response replaces the whole body; add the properties you need.

Relying on cause or description for client logic

cause is never serialized; use errorCode for machine-readable identifiers.

Putting secrets or internal data in detail, message or errorCode

These are returned to the caller. Keep diagnostics in cause and in logs.

Assuming exception is always an Error

Anything can be thrown, so a catch-everything filter must treat its argument as unknown.

What changed recently

  • errorCode (Nest 12). HttpExceptionOptions accepts an errorCode, serialized into the response body for the built-in filter, available on HttpException and all built-in exceptions.

  • IntrinsicException. HttpException is 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 .js extension in an ESM project, as the examples do.

  • BaseExceptionFilter knows more error shapes. It recognizes http-errors-style errors, Fastify errors and plain status-carrying objects, and it ends the response when headers were already sent (see Unrecognized exceptions).

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.