How NestJS error handling flows from throw to response

An exception filter is a class with a catch(exception, host) method that turns a thrown error into a transport response. The host argument is an ArgumentsHost. It exposes the execution context, such as the HTTP request and response or the RPC data and metadata, so the filter can build a reply without the handler knowing how it will be delivered.

Where filters sit

A request passes through middleware, guards, interceptors, pipes and then the route handler. If guards, interceptors, pipes or the handler throw, Nest routes the exception to the exceptions layer. That layer runs any filters bound at method, controller or global scope, then falls back to the built-in one.

On HTTP, the built-in filter serializes HttpException subclasses using their status and body. Anything else becomes a 500 with a generic message. On RPC transports, the default filter handles RpcException and replaces other errors with a generic internal error. An HttpException thrown inside a gRPC handler does not become a meaningful gRPC status, so clients typically see an unhelpful generic failure. RPC filters must also return an Observable rather than write to a response object.

Filters cover only work Nest dispatches. Errors during bootstrap, in raw middleware attached directly to the underlying HTTP adapter, or in un-awaited async callbacks can bypass them entirely. You still need process-level handlers and logging.

The approach in this article

One common pattern is a single catch-all filter that branches on host.getType(). It looks tidy but mixes two response contracts in one class. This tutorial uses a different design: a transport-agnostic domain error model, one shared status mapping, and a thin filter per transport.

Request (HTTP or gRPC)
 |
 v
+------------+  raw adapter middleware, bootstrap and un-awaited
| Middleware |  async errors can bypass filters -> process handlers
+------------+
 |
+--------+   +--------------+   +-------+   +---------+
| Guards |-->| Interceptors |-->| Pipes |-->| Handler |
+--------+   +--------------+   +-------+   +---------+
 |  throw          |                |            |
 +-----------------+-------+--------+------------+
                           v
  +----------------------------------------------+
  | Exceptions layer: method > controller >      |
  | global filters, then built-in default        |
  +----------------------------------------------+
         | HTTP context              | RPC context
         v                           v
  +-------------------+   +----------------------+
  | HTTP filter       |   | gRPC filter          |
  | writes status +   |   | returns Observable   |
  | JSON body         |   | with gRPC status     |
  +-------------------+   +----------------------+
         |    both use shared mapping |
         +-------------+--------------+
                       v
  +----------------------------------------------+
  | Transport-agnostic domain error + status map |
  +----------------------------------------------+

Exceptions thrown by guards, interceptors, pipes or handlers reach the Nest exceptions layer. That layer routes them to a thin HTTP or gRPC filter, and both filters rely on one shared domain error model, while bootstrap, raw adapter middleware and un-awaited async errors can bypass filters entirely.

Designing a transport-agnostic domain error model

The foundation is a single base class, DomainError, extending Error. It carries four fields, each with one job:

  • code: a stable, machine-readable string such as ORDER_NOT_FOUND or PAYMENT_DECLINED. Clients branch on this, so treat it as part of your public API. Renaming it is a breaking change.
  • message: text that is safe to show a caller. It must never contain SQL fragments, hostnames, or stack details.
  • details: optional structured data, such as the field that failed validation or the conflicting resource ID, kept as plain serializable values.
  • cause: the underlying error, kept for logs and traces. Filters must never serialize it. Make that explicit by excluding it from any toJSON method rather than relying on reviewers to remember.

Subclasses like NotFoundError or ConflictError fix the category. Concrete errors then add their own codes.

Domain and service code throws these instead of HttpException or RpcException because those classes encode a transport decision. A service that throws NotFoundException has decided the caller speaks HTTP. When the same service backs a gRPC handler, that assumption is wrong. You either duplicate the logic or translate exceptions ad hoc in each controller. Teams that run more than one transport, or expect to add one, get the most out of this approach. A single-transport service gains less.

The cost is habit. Engineers used to throwing HttpException from services must stop, and a lint rule that bans the import outside controllers helps. Third-party libraries and some Nest internals will keep throwing HttpException regardless, so the filters still have to handle those alongside DomainError.

// Requires "lib": ["ES2022"] or later in tsconfig for Error's `cause` option.
export abstract class DomainError extends Error {
  constructor(
    // Stable and public: clients branch on it, so renaming it is a breaking change.
    readonly code: string,
    // Safe to show callers: no SQL, hostnames or stack details.
    message: string,
    // Plain serializable values only, e.g. { field: 'email' } or { orderId }.
    readonly details?: Record<string, unknown>,
    // Internal only. Kept for logs and traces, never sent to clients.
    cause?: unknown,
  ) {
    super(message, { cause });
    this.name = new.target.name;
  }

  // Explicit allow-list: `cause` and `stack` cannot leak through JSON.stringify.
  toJSON() {
    return { code: this.code, message: this.message, details: this.details };
  }
}

// Categories: filters later map these to an HTTP status or a gRPC status code.
export abstract class NotFoundError extends DomainError {}
export abstract class ConflictError extends DomainError {}

// Concrete errors fix their own code and safe message.
export class OrderNotFoundError extends NotFoundError {
  constructor(orderId: string, cause?: unknown) {
    super('ORDER_NOT_FOUND', 'Order not found.', { orderId }, cause);
  }
}

export class OrderAlreadyPaidError extends ConflictError {
  constructor(orderId: string, cause?: unknown) {
    super('ORDER_ALREADY_PAID', 'Order has already been paid.', { orderId }, cause);
  }
}

// In a service: throw new OrderNotFoundError(id), never NotFoundException or RpcException.

A base DomainError with a stable code, safe message, serializable details and a cause that toJSON deliberately excludes, plus category and concrete subclasses that service code throws without choosing a transport.

One mapping module for HTTP and gRPC status codes

All of that mapping logic lives in one module: a frozen object keyed by domain code, where each entry holds an HTTP status number and a member of the status enum exported by @grpc/grpc-js. Neither filter decides status codes on its own. Both read this table, so a REST client and a gRPC client calling the same use case always see the same classification of failure.

Most codes translate directly. The difficult ones are where HTTP collapses several meanings into one status, especially 409.

Domain code HTTP gRPC Reasoning
ORDER_NOT_FOUND 404 NOT_FOUND Direct equivalent
ORDER_ALREADY_EXISTS 409 ALREADY_EXISTS Retrying the same create will never succeed
ORDER_VERSION_CONFLICT 409 ABORTED Optimistic-lock clash; the client should re-read and retry
ORDER_NOT_CANCELLABLE 409 FAILED_PRECONDITION The system state must change first; a blind retry is pointless

The three 409 rows look identical to an HTTP caller. They carry different retry semantics for gRPC clients, and retry policies and client libraries make decisions based on these status codes. Write the reasoning next to each non-obvious entry in the module itself. That way, whoever adds ORDER_LOCKED next year can see which precedent applies instead of guessing.

The unit test iterates over every value of the domain code enum and asserts three things:

  • an entry exists for the code
  • its HTTP status falls between 400 and 599
  • its gRPC value is a member of status and is not OK

Typing the table as Record<DomainCode, …> catches missing keys at compile time. The test also catches entries that are present but wrong, such as a placeholder 0, which is OK, or a copied 200.

// error-map.ts: the single source of truth read by both exception filters
import { HttpStatus } from '@nestjs/common';
import { status } from '@grpc/grpc-js';

export enum DomainCode {
  ORDER_NOT_FOUND = 'ORDER_NOT_FOUND',
  ORDER_ALREADY_EXISTS = 'ORDER_ALREADY_EXISTS',
  ORDER_VERSION_CONFLICT = 'ORDER_VERSION_CONFLICT',
  ORDER_NOT_CANCELLABLE = 'ORDER_NOT_CANCELLABLE',
}

// Record<DomainCode, ...> turns a missing key into a compile error.
export const ERROR_MAP: Readonly<Record<DomainCode, { http: number; grpc: status }>> = Object.freeze({
  [DomainCode.ORDER_NOT_FOUND]: { http: HttpStatus.NOT_FOUND, grpc: status.NOT_FOUND },
  // Duplicate create: retrying the same request can never succeed.
  [DomainCode.ORDER_ALREADY_EXISTS]: { http: HttpStatus.CONFLICT, grpc: status.ALREADY_EXISTS },
  // Optimistic-lock clash: client should re-read, then retry the read-modify-write.
  [DomainCode.ORDER_VERSION_CONFLICT]: { http: HttpStatus.CONFLICT, grpc: status.ABORTED },
  // State must change first (e.g. already shipped); a blind retry is pointless.
  [DomainCode.ORDER_NOT_CANCELLABLE]: { http: HttpStatus.CONFLICT, grpc: status.FAILED_PRECONDITION },
});

// error-map.spec.ts: catches entries that exist but hold wrong values
import { status } from '@grpc/grpc-js';
import { DomainCode, ERROR_MAP } from './error-map';

describe('ERROR_MAP', () => {
  it.each(Object.values(DomainCode))('%s maps to valid HTTP and gRPC statuses', (code) => {
    const entry = ERROR_MAP[code];
    expect(entry).toBeDefined();
    expect(entry.http).toBeGreaterThanOrEqual(400);
    expect(entry.http).toBeLessThanOrEqual(599);
    // Numeric TS enums have a reverse mapping, so a real member resolves to its name.
    expect(typeof status[entry.grpc]).toBe('string');
    // A placeholder 0 is status.OK, which is never a valid error mapping.
    expect(entry.grpc).not.toBe(status.OK);
  });
});

A frozen domain-code table that maps each code to an HTTP status and a @grpc/grpc-js status, with the reasoning for each 409 split written beside the entry, plus a Jest test that checks every enum value for a valid mapping on both transports.

Writing the HTTP and gRPC exception filters

The HTTP filter is decorated with a bare @Catch() so it sees every exception, not only HttpException. Inside catch(exception, host), call host.switchToHttp() to get the request and response, then branch three ways.

  1. DomainError: look up its code in the shared table and use the HTTP status and public message from that entry.
  2. HttpException: use getStatus() and getResponse() as they are, so ValidationPipe output and framework 404s keep their semantics. Reshape the body into the global error response of code, message, details and requestId. Decide on purpose how much validation detail goes into details. Field paths and constraint names help clients. Echoing rejected input values can leak data.
  3. Anything else: log the full error and stack with the request ID, then return a generic 500 with a fixed message.

Treat the response as an allowlist. Only fields you deliberately copy reach the client. Never pass error.message through by default.

The gRPC filter either implements RpcExceptionFilter or extends BaseRpcExceptionFilter. It runs the same three-way branch, but returns throwError(() => ...) with an object carrying the mapped gRPC status code and message. The unknown-error branch returns INTERNAL with a generic message.

Structured detail has two possible homes:

  • The message string. Any client can read it.
  • Trailing metadata, including the rich google.rpc.Status details. This form is typed, but client support for decoding it varies by language and library. Polyglot consumers may only ever see the code and message.

The exact error object the NestJS gRPC transport accepts, such as code versus status or how metadata is attached, has differed across releases. Verify it against your installed NestJS and gRPC package versions.

import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
// Shared table: code -> { httpStatus, grpcCode, publicMessage }
import { DomainError, ERROR_TABLE } from './errors';

@Catch() // bare: catch everything, not only HttpException
export class HttpErrorFilter implements ExceptionFilter {
  private readonly logger = new Logger(HttpErrorFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const req = ctx.getRequest<Request>();
    const res = ctx.getResponse<Response>();
    const requestId = String(req.headers['x-request-id'] ?? 'unknown');

    if (exception instanceof DomainError) {
      const entry = ERROR_TABLE[exception.code];
      // Public message comes from the table, never from exception.message
      return res.status(entry.httpStatus).json({ code: exception.code, message: entry.publicMessage, details: [], requestId });
    }

    if (exception instanceof HttpException) {
      const status = exception.getStatus(); // keep framework semantics (400, 404, ...)
      const body = exception.getResponse();
      const msg = typeof body === 'object' && body !== null ? (body as { message?: unknown }).message : body;
      // ValidationPipe puts constraint messages in an array by default; copy only those strings,
      // not rejected values or the raw body
      const details = Array.isArray(msg) ? msg.map(String) : [];
      const message = Array.isArray(msg) ? 'Validation failed' : String(msg ?? 'Request failed');
      return res.status(status).json({ code: `HTTP_${status}`, message, details, requestId });
    }

    // Unknown error: full detail goes to logs only, and the client gets a fixed message
    const stack = exception instanceof Error ? exception.stack : String(exception);
    this.logger.error(`requestId=${requestId} unhandled error`, stack);
    return res.status(500).json({ code: 'INTERNAL', message: 'Internal server error', details: [], requestId });
  }
}

A global Express-based NestJS HTTP exception filter that maps DomainError through the shared table, reshapes HttpException and ValidationPipe output into the code/message/details/requestId shape, and returns a generic logged 500 for anything else.

Registering filters and propagating errors across services

Choosing a registration method

app.useGlobalFilters() APP_FILTER provider
Dependency injection None; you construct the filter yourself Full; inject a logger, config, or metrics client
Where it lives main.ts, outside any module Inside a module's providers array
Hybrid app coverage HTTP only, unless the microservice inherits app config Verify against your version
Testing Easy to forget in e2e setups Picked up automatically when the module is imported

APP_FILTER is usually the better default because filters often need a logger or a flag for exposing stack traces. The cost is that registration now depends on module structure. If you move the provider into a feature module, it is still applied globally, which can surprise readers who expect module scoping.

In a hybrid app, filters registered through useGlobalFilters() do not reach the gRPC listener by default. Pass { inheritAppConfig: true } as the second argument to connectMicroservice(), or register the gRPC filter explicitly.

Only one filter handles a given exception. Nest checks method-level filters first, then controller-level, then global. Within a scope, the filter whose @Catch() matches is used. When you register a catch-all filter next to a specific one, declare the catch-all first so the specific filter takes precedence.

Propagating across the gateway

When the REST gateway calls a gRPC service, a failure arrives as an error carrying a numeric gRPC status code and a details string. If the gateway lets that error propagate unchanged, the HTTP filter has no matching rule and returns a 500. To avoid this, catch the error at the client call, reverse-map the gRPC code through the shared mapping module, and rethrow it as a DomainError. A NOT_FOUND from the service then reaches the external caller as a 404.

How it works end to end

Follow one failure, a lookup for an order that does not exist, through every path the design supports.

  1. The service throws once. OrdersService.getOrder(id) finds no row and throws new OrderNotFoundError(id). It does not know or care which transport invoked it. Any database error that caused the miss travels in cause and stays server-side.
  2. The REST path. A controller method calls the service without a try/catch. The exception propagates out of the handler into the exceptions layer. With no method- or controller-level filter bound, Nest uses the global HttpErrorFilter. The instanceof DomainError branch matches, the filter looks up ORDER_NOT_FOUND in the shared table, and it writes a 404 with a body of code, message, details and requestId.
  3. The gRPC path. A @GrpcMethod handler calls the same service method. The same exception reaches the exceptions layer, but the execution context is RPC, so the gRPC filter handles it. It looks up the same table entry and returns throwError(() => ...) with NOT_FOUND. The gRPC client receives that status code and the safe message.
  4. The gateway path. When the REST gateway calls the gRPC service and gets NOT_FOUND back, the client-call wrapper reverse-maps the status through the mapping module and rethrows a DomainError. The gateway's own HTTP filter then turns it into a 404. The external caller sees the same contract as if the service had answered directly. Reverse mapping by gRPC status alone is ambiguous when several domain codes share one gRPC status, so carry the domain code in the message or metadata and prefer it when present.
  5. The unknown-error path. If something unexpected escapes, such as a TypeError in a handler, the fallback branch logs the stack with the request ID and returns a generic 500 or INTERNAL. Nothing internal reaches the caller, and the request ID connects the client report to the log line.

Each part has one job. Services decide what failed, the mapping module decides how each transport classifies it, and each filter decides how to serialize it. Changing one of these does not require touching the others.

Testing the filters with mock hosts and real transport calls

Unit tests with a mock ArgumentsHost

Filters only touch the host through a few methods, so a hand-built stub covers them. For the HTTP filter, stub switchToHttp() to return an object whose getResponse() yields a fake response with a chainable status() and a spy on json(). Throw each DomainError subtype through catch() and assert the status code and body shape. Also include one plain Error, so the fallback path to a generic 500 stays tested. For the gRPC filter, stub switchToRpc() and getType() to return 'rpc'. Subscribe to the returned observable and assert that the emitted error carries the expected numeric gRPC code and the serialized details. These tests run in milliseconds and pinpoint which mapping or serialization step broke.

End-to-end tests over real transports

Mocks cannot catch registration mistakes, such as a filter bound only to HTTP or a missing connectMicroservice call. Boot the application from the testing module with both transports on a dedicated test port. Send a real request with supertest and assert the status code and JSON body. Then call the same use case through a real gRPC client and assert err.code and the error metadata keys. This confirms that the client sees what the filters produce.

Why the layers matter together

The mapping completeness test fails when someone adds a domain error without mapping it. The unit tests fail when a filter's serialization drifts. The end-to-end tests fail when wiring changes silently bypass a filter. Together they catch the most common regressions before deployment.

When to use this approach, when to avoid it, and what it costs

The pattern pays off when the same domain error has to reach more than one kind of client. Typical cases are a REST gateway in front of internal gRPC services, or several teams who need ORDER_NOT_FOUND to mean the same thing everywhere. Each new service or transport reuses the model and the mapper instead of re-deciding status codes.

Skip it for a single REST-only service, a prototype, or a small app. In those, HttpException and its subclasses already give you a status, a message and a predictable body. A domain layer would add files without serving a second consumer.

What are the alternatives?

Approach Works when Breaks down when
Domain errors + per-transport filters Multiple transports, shared codes One small service, where the overhead dominates
One catch-all filter branching on host.getType() Two transports, one team Branches grow and HTTP and gRPC logic tangle in one file
Throwing HttpException / RpcException directly One transport Domain code imports transport types, so reuse across transports means rethrowing
Built-in default handling Prototypes Unknown errors collapse to generic messages with inconsistent shapes and no request IDs

One catch-all filter. A single @Catch() class that checks host.getType() and returns either an HTTP response or an RPC error. It has fewer files and one registration. You can still combine it with the domain error model and shared mapping table, which removes most of the duplication. The cost is that two response contracts, plus their logging and metadata handling, live in one class that grows with each transport.

Transport exceptions thrown from services. Services throw NotFoundException or RpcException directly. This is the least code for a single transport and matches most NestJS examples. It stops working well when a second transport calls the same service, because each new caller has to catch and translate errors it was never meant to see.

Error mapping in an interceptor. An interceptor can wrap the handler's observable with RxJS catchError and convert domain errors into HttpException or RpcException before the default filters run. This works, but interceptors run after guards, so errors thrown by guards never pass through them. You still need a filter for consistent fallback handling, so the interceptor ends up adding a second place where errors are shaped.

Built-in defaults. Doing nothing is acceptable for a prototype. You give up a stable body shape, request IDs in responses and control over what unknown errors look like.

How it saves engineering time

Costs. You pay upfront for the model, the mapper and the tests. Debugging gains an extra hop, from throw site to mapper to filter. You also have to migrate existing throws, which you can do one module at a time.

Savings.

  • Endpoints stop formatting their own errors. Controllers and gRPC handlers contain no try/catch blocks for translating errors, so new endpoints need no error-handling code beyond throwing the right DomainError.
  • Status decisions happen once. Adding an error code means adding one table entry, and the completeness test points out a missing one. Reclassifying an error, such as moving a conflict from ABORTED to FAILED_PRECONDITION, is a one-line change instead of a search across handlers.
  • Clients code against one error contract. Front-end and service consumers write one error parser and branch on code, rather than handling per-endpoint body shapes.
  • Gateway work shrinks to one reverse-mapping wrapper instead of per-call translation logic.
  • Stack traces and driver messages stop leaking to callers, which removes a class of security review findings and follow-up fixes.
  • Request IDs join gateway logs to service logs, which speeds up debugging.

Illustrative estimate (assumed numbers, not a benchmark).

Break-even endpoints = upfront hours ÷ hours saved per endpoint

Assume 24 hours of upfront work and 0.5 hours saved per endpoint. Then 24 ÷ 0.5 = 48 endpoints before the pattern pays for itself.

The assumed per-endpoint saving only counts writing and reviewing error handling. If you also assume time saved on incidents, the break-even point comes earlier. For example, with an assumed 6 hours per year saved by not tracing mismatched status codes between gateway and services, the remaining 18 hours need 18 ÷ 0.5 = 36 endpoints. Replace these assumed inputs with your own figures before using the result.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nestjs exception filter, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.