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
Read first
The problem
Section titled “The problem”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?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| 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 |
Core concepts
Section titled “Core concepts”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 ofAorB. An empty@Catch()catches everything.ArgumentsHost: a wrapper around the handler’s arguments.host.switchToHttp()providesgetRequest()andgetResponse(); for microservices or WebSockets useswitchToRpc(),switchToWs().- Scope: method, class (
@UseFilters()) or global.
How it works
Section titled “How it works”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:
DatabaseError 23505The 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.
Operational details
Section titled “Operational details”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 thatextends BaseExceptionFilteralso getsHttpAdapterHostinjected automatically.- Using
BaseExceptionFilterwithuseGlobalFiltersrequires passing the adapter:new X(app.get(HttpAdapterHost).httpAdapter). At method/controller level, pass the class (@UseFilters(X)), notnew. - A filter writes the response itself, so it sets
Content-Typeitself; the default isapplication/json; charset=utf-8. - Scope: HTTP,
@nestjs/platform-express11.2.6.
Real-world example
Section titled “Real-world example”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 errorexport 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):
npx --prefix examples ts-node examples/src/nestjs-exception-filters/errors-demo.tsReal 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-8POST /users-with-audit [process] unhandledRejection: audit log: connection refused -> 201 content-type: application/json; charset=utf-8 {"id":5} [process] uncaughtException: welcome email: SMTP timeoutReading the output:
- All three error shapes are now
application/problem+json. Thepgerror is aDatabaseError; the filter recognizes it bycode. /legacy/stats: the controller hasLegacyFilter, but an error thrown from middleware still reaches the global filter. Verified, matching the docs./legacy/boom:@Catch(HttpException)does not matchTypeError, so the error falls through to global./users-with-auditreturns201: anasynclistener rejects and asetTimeoutthrows after the handler finishes. No filter runs; withoutprocess.on(...), Node 24 terminates the process.
Errors that never reach a filter
Section titled “Errors that never reach a filter”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.
Trade-offs
Section titled “Trade-offs”One global filter
- One place decides the format, the status and what error detail is hidden.
- Clients branch on
typeinstead of parsingmessage. - Consistent
5xxlogging 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.
Production checklist
Section titled “Production checklist”- Register a global
@Catch()filter viaAPP_FILTERso it gets DI for the logger and config. - Every error response sets
Content-Type: application/problem+json; test status, header and body over real HTTP. - Map DB errors by
code+constraint; unrecognized errors become500with a genericdetail. - Log
5xxwith the request id and stack; return the request id viainstanceor a header. - Never put
exception.messageof a non-HttpExceptionerror into the body. - Wrap listeners, timers and consumers in
try/catch; registerunhandledRejectiononly to log and then stop in a controlled way (graceful shutdown).
Common mistakes
Section titled “Common mistakes”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, SQLPrefer
logger.error({ requestId, err: e }); // details go to the logres.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 |
Self-check
Section titled “Self-check”- A controller has
@UseFilters(A)with@Catch(HttpException), and the global filter B has@Catch(). The handler throws aTypeError: who writes the response? - Why doesn’t an error thrown in middleware reach the controller filter?
- With no custom filter, what body does a
23505error return, and where is it logged? - A handler calls
events.emit()and a synchronous listener throws: what is the response? What if the listener isasync?
Summary
Section titled “Summary”References
Section titled “References”- Exception filters — NestJS: built-in layer,
APP_FILTER,BaseExceptionFilter, middleware. - Execution context — NestJS:
ArgumentsHost,switchToHttp. - Source
packages/core/exceptions:ExceptionsHandlerpicks the first matching filter. - PostgreSQL Error Codes:
23505 unique_violation. - API errors with RFC 9457: what each Problem Details field means.