Skip to content
Knowledge Hub

Exception filters in NestJS: one error format, no leaked SQL, and knowing which errors never reach a filter Intermediate

Estimated reading time: 10 min read

An orders and user-signup API returns errors in three shapes. The mobile app can only parse the first. Real output on NestJS 11.2.6:

GET /orders/42 -> 404 {"message":"Order 42 not found","error":"Not Found","statusCode":404}
POST /users (bad email) -> 400 {"message":["email must be an email", "..."],"error":"Bad Request","statusCode":400}
POST /v0/users (duplicate) -> 500 {"statusCode":500,"message":"duplicate key value violates unique constraint \"users_email_key\""}

message is sometimes a string, sometimes an array. A duplicate email (Postgres SQLSTATE 23505) becomes a 500, and the hand-written filter on the /v0 route even returns the raw SQL error, leaking the constraint name.

The question: where do you put one place that gives every HTTP error the same format, turns a duplicate email into 409, hides details of unexpected errors, and which errors will never pass through it?

Situation Choose Condition
Every route needs the same error format A global @Catch() filter via APP_FILTER The filter needs DI (logger, config)
A legacy controller keeps the default body A controller filter that extends BaseExceptionFilter and calls super.catch() Catch only the error types to preserve
A DB error with business meaning (23505) Map it in the global filter or the repository By code + constraint, not by message
Errors from middleware Only the global filter handles them Route/controller filters are not considered
Errors in async listeners, setTimeout, consumers Your own try/catch + logging inside that callback Filters do not see these errors

An exception filter is a class with a catch(exception, host) method that receives unhandled errors and writes the response itself. Nest always has a default handling layer (the built-in exception layer).

Error thrown Default layer returns Note
HttpException or a subclass (NotFoundException, ConflictException…) getStatus() and getResponse() Passing a string yields a body of {message, error, statusCode}
An object with statusCode + message (http-errors style) Those two values Partially supported
Everything else (TypeError, pg errors) 500 {"statusCode":500,"message":"Internal server error"} Logged via Logger('ExceptionsHandler')

By default SQL is not leaked; the leak comes from a hand-written filter returning exception.message. HttpException accepts a cause option (new ConflictException('...', { cause: err })). Per the docs, cause is not serialized into the response; it is only for logging.

The remaining three concepts:

  • @Catch(A, B): the filter only receives errors that are instances of A or B. An empty @Catch() catches everything.
  • ArgumentsHost: a wrapper around the handler’s arguments. host.switchToHttp() provides getRequest() and getResponse(); for microservices or WebSockets use switchToRpc(), switchToWs().
  • Scope: method, class (@UseFilters()) or global.

Each route has an ExceptionsHandler holding a list of filters in the order route, controller, global. When an error occurs, it picks the first filter whose @Catch matches; if none matches, the default handling applies. The diagram follows the 23505 error from POST /users:

A 23505 error passes the route, controller, then global filter; middleware errors only reach global; async listener errors reach no filter
A 23505 error passes the route, controller, then global filter; middleware errors only reach global; async listener errors reach no filterStep 1/6: The handler runs INSERT and Postgres returns error code 23505. The exception goes up to the route's ExceptionsHandler.Filters, checked fromlowest to highestHandlerPOST /usersthrowMiddlewareTenantMiddlewareAsync listeneror setTimeoutRoute filter@UseFilters on methodController filter@UseFilters on classGlobal filterProblemDetailsFilterBuilt-in handlerBaseExceptionFilterHTTP responsestatus + bodyprocessunhandledRejectionDatabaseError 23505
Handler → Route filterDatabaseError 23505
Step 1/6

The handler runs INSERT and Postgres returns error code 23505. The exception goes up to the route's ExceptionsHandler.

Step Who receives the error Result
1 The route’s ExceptionsHandler Starts searching for a filter
2 Route and controller filters Skipped if absent or @Catch(HttpException) does not match
3 Global ProblemDetailsFilter Matches, returns 409; only one filter handles each error
4 No filter matches Default: 500 and a log entry
5 Error in middleware No route selected yet, global filter only
6 Async listener after 201 is returned Outside the request, falls through to process

With multiple global filters, the docs advise declaring the catch-all filter before specific ones, because the list is traversed in reverse.

  • app.useGlobalFilters(new X()): created outside any module, so no DI; the docs note it does not attach the filter to gateways or hybrid apps.
  • { provide: APP_FILTER, useClass: X } in any module: a global filter with dependency injection. A filter that extends BaseExceptionFilter also gets HttpAdapterHost injected automatically.
  • Using BaseExceptionFilter with useGlobalFilters requires passing the adapter: new X(app.get(HttpAdapterHost).httpAdapter). At method/controller level, pass the class (@UseFilters(X)), not new.
  • A filter writes the response itself, so it sets Content-Type itself; the default is application/json; charset=utf-8.
  • Scope: HTTP, @nestjs/platform-express 11.2.6.

The filter, trimmed from the demo; the demo prints logs with console instead of Logger (for the meaning of the Problem Details fields, see the RFC 9457 article):

@Catch() // catch every error
export class ProblemDetailsFilter implements ExceptionFilter {
private readonly logger = new Logger('Problem');
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const req = ctx.getRequest<Request & { id: string }>();
const problem = toProblem(exception); // HttpException, 23505, everything else 500
if (problem.status >= 500) {
this.logger.error({ requestId: req.id, err: exception }); // details only in the log
}
ctx.getResponse<Response>().status(problem.status)
.type('application/problem+json')
.json({ ...problem, instance: `urn:uuid:${req.id}` });
}
}

toProblem decides the status and type in order, with the last branch as the safety net:

Loading diagram…

req.id is set by a middleware (taking x-request-id if valid, otherwise randomUUID()); to read the id in services, use AsyncLocalStorage. Run it against a real Postgres (a real 23505 from a unique constraint, schema demo_nestjs_exceptions, dropped when done):

Terminal window
npx --prefix examples ts-node examples/src/nestjs-exception-filters/errors-demo.ts
Real output (instance trimmed, id fixed via the x-request-id header)
POST /users (duplicate email)
[filter] global ProblemDetailsFilter (DatabaseError)
-> 409 content-type: application/problem+json; charset=utf-8
{"type":"https://api.example.org/problems/email-taken","title":"Email already registered","status":409,"detail":"This email is already registered.","instance":"urn:uuid:3f6c2a8e-..."}
POST /users (bad email, 1-character name)
-> 422 content-type: application/problem+json; charset=utf-8
{"type":"https://api.example.org/problems/validation-error","title":"Request body is not valid","status":422,"errors":[{"pointer":"#/email","detail":"email must be an email"},{"pointer":"#/name",...}],...}
GET /boom (TypeError in handler)
[log] ERROR requestId=3f6c2a8e-... TypeError: Cannot read properties of undefined (reading 'total')
-> 500 content-type: application/problem+json; charset=utf-8
{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Unexpected error. Quote the instance...",...}
GET /legacy/stats (TenantMiddleware throws 400, controller has LegacyFilter)
[filter] global ProblemDetailsFilter (HttpException)
-> 400 {"type":"about:blank","title":"Bad Request","status":400,"detail":"Missing x-tenant header",...}
GET /legacy/orders/42
[filter] controller LegacyFilter -> super.catch()
-> 404 content-type: application/json; charset=utf-8
{"message":"Order 42 not found","error":"Not Found","statusCode":404}
GET /legacy/boom (TypeError, LegacyFilter only @Catch(HttpException))
[filter] global ProblemDetailsFilter (TypeError)
-> 500 content-type: application/problem+json; charset=utf-8
POST /users-with-audit
[process] unhandledRejection: audit log: connection refused
-> 201 content-type: application/json; charset=utf-8
{"id":5}
[process] uncaughtException: welcome email: SMTP timeout

Reading the output:

  1. All three error shapes are now application/problem+json. The pg error is a DatabaseError; the filter recognizes it by code.
  2. /legacy/stats: the controller has LegacyFilter, but an error thrown from middleware still reaches the global filter. Verified, matching the docs.
  3. /legacy/boom: @Catch(HttpException) does not match TypeError, so the error falls through to global.
  4. /users-with-audit returns 201: an async listener rejects and a setTimeout throws after the handler finishes. No filter runs; without process.on(...), Node 24 terminates the process.

Filters only see errors on the request’s await chain (or Observable). The following sit outside it:

Error source Why it never reaches a filter What to do
An un-awaited promise (void this.audit()) The handler has already returned; the promise rejects later await, or .catch() with logging
An async event emitter listener emit() does not wait for listener promises try/catch inside the listener
setTimeout, setInterval, cron The callback runs on another tick, with no request Wrap the callback, log with the job id
Kafka/RabbitMQ consumers, job queues There is no HTTP request; HTTP filters don’t apply Handle errors via the consumer’s retry/DLQ

A synchronous listener that throws is different: emit() throws right inside the handler, so the error still reaches the filter. For details on unhandledRejection, see async errors in Node.js.

One global filter

  • One place decides the format, the status and what error detail is hidden.
  • Clients branch on type instead of parsing message.
  • Consistent 5xx logging with a request id.

The cost

  • The filter knows about both DB and HTTP errors and tends to bloat.
  • Changing the format is a breaking change for clients parsing the default body.
  • It does not cover errors outside a request.
  1. Register a global @Catch() filter via APP_FILTER so it gets DI for the logger and config.
  2. Every error response sets Content-Type: application/problem+json; test status, header and body over real HTTP.
  3. Map DB errors by code + constraint; unrecognized errors become 500 with a generic detail.
  4. Log 5xx with the request id and stack; return the request id via instance or a header.
  5. Never put exception.message of a non-HttpException error into the body.
  6. Wrap listeners, timers and consumers in try/catch; register unhandledRejection only to log and then stop in a controlled way (graceful shutdown).

The most common mistake is a catch-all filter that returns the original message:

Returning exception.message for every error

Common

res.status(500).json({ statusCode: 500, message: e.message }); // leaks constraint names, SQL

Prefer

logger.error({ requestId, err: e }); // details go to the log
res.status(500).type('application/problem+json')
.json({ type: 'about:blank', title: 'Internal Server Error', status: 500, instance });

Why

Messages from DB or library errors are written for operators, not for clients. See also secure coding.

The second mistake concerns ordering:

Misconception Consequence What to do
useGlobalFilters(new X()) has DI Dependencies are undefined Use APP_FILTER
cause appears in the response The client never sees it Log the cause
Filters catch errors in setTimeout The process crashes while the response is still 201 try/catch in the callback
  1. A controller has @UseFilters(A) with @Catch(HttpException), and the global filter B has @Catch(). The handler throws a TypeError: who writes the response?
  2. Why doesn’t an error thrown in middleware reach the controller filter?
  3. With no custom filter, what body does a 23505 error return, and where is it logged?
  4. A handler calls events.emit() and a synchronous listener throws: what is the response? What if the listener is async?