Exception Filters
How NextRush lets a controller or route declare its own error-to-response mapping — scoped, typed handling that sits beside the framework's default HttpError response, not instead of it.
The default error handling already turns any thrown error into a safe response — errors covers that global boundary. But a UserController and a BillingController can want the same thrown error to become a different shape: one a 404 with a resourceType field, the other a 404 with an invoiceId. Push both into the one global handler and it grows a branch per controller, coupling unrelated features through shared error-handling code.
What you'll learn
- Understand why a single global error handler can't express every controller's domain-specific error shape
- Understand how
@Catch(...)+@UseFilter(...)scope a typed error-to-response mapping to one controller or route - Recognize how a filter differs from a plain
try/catchand from the framework's defaultHttpErrorhandling - Choose between a global error handler and a scoped filter for a given error case
The problem
The direct way to give one controller a custom error shape is a try/catch around every method that can throw it:
// The hand-rolled approach — every method repeats its own catch and response shape.
import { Controller, Get } from 'nextrush/class';
import { NotFoundError } from 'nextrush';
import type { Context } from 'nextrush';
declare function findUser(id: string): Promise<unknown>;
@Controller('/users')
class UserController {
@Get('/:id')
async findById(ctx: Context) {
try {
const user = await findUser(ctx.params.id);
if (!user) throw new NotFoundError('User not found');
return user;
} catch (error) {
if (error instanceof NotFoundError) {
ctx.status = 404;
ctx.json({ error: 'Resource not found', resourceType: 'user' }); // this controller's shape
return;
}
throw error; // anything else still needs to escape correctly
}
}
}Every method that can throw NotFoundError repeats the same catch, the same instanceof check, and the same response shape. Add a second route to the controller and the block is copy-pasted; forget the throw error at the bottom and an error type this catch wasn't meant for is silently swallowed instead of reaching the framework's default handling.
Why this matters
A per-method try/catch is exactly the duplication errors already solves at the application level — repeated here at the controller level. Domain-specific error shaping is a real, recurring need (a resourceType field here, an invoiceId there), but solving it by hand-rolling a catch in every handler reintroduces the drift the global error handler was built to remove: one method's catch block quietly diverges from another's, and the fallthrough throw error that keeps unrelated errors working correctly gets left out more often than not.
The solution
NextRush lets a controller or route declare its own error-to-response mapping with an exception filter — a class that declares which errors it handles with @Catch(...) and turns a thrown error into a response with catch(error, ctx), attached via @UseFilter(...). A filter only ever sees errors thrown while its controller or route runs, and only for the types its @Catch names; anything else is rethrown, unchanged, straight to the framework's default HttpError handling. Filters are resolved from the DI container, so — like guards and interceptors — a filter's constructor can inject services such as a logger or an error-tracking client.
Core idea
Think of a filter as a scoped, typed catch block that the framework wires around a controller or route for you. It is not a replacement for the framework's default error handling — it is a narrower catch that runs first, for specific error types, on specific routes, and hands off to the default handling for everything it doesn't recognize. A plain try/catch you write by hand has no such handoff: you own deciding what to rethrow, and a forgotten throw error breaks it.
Mental model
Both branches start the same way — the handler throws and the runner catches it. Don't read the two outcomes as "handled vs. crashed": the "no match" branch isn't a failure, it's the filter runner stepping aside so the error reaches exactly the default handling it would have reached with no filters attached at all.
Quick example
Attach a filter to one controller, and only the error type it declares is affected — everything else keeps using the framework's default response:
import { createApp, listen } from 'nextrush';
import { Controller, Get, Service, Catch, UseFilter, registerControllers } from 'nextrush/class';
import type { ExceptionFilter } from 'nextrush/class';
import type { Context } from 'nextrush';
import { NotFoundError } from 'nextrush';
// A filter: declare what it catches with @Catch, respond with catch(error, ctx).
@Service()
@Catch(NotFoundError)
class NotFoundFilter implements ExceptionFilter {
catch(error: unknown, ctx: Context): void {
ctx.status = 404;
ctx.json({ error: 'Resource not found', resourceType: 'user' });
}
}
@UseFilter(NotFoundFilter)
@Controller('/users')
class UserController {
@Get('/:id')
findById(): never {
throw new NotFoundError('User not found'); // caught by NotFoundFilter
}
}
const app = createApp();
await registerControllers(app, { controllers: [UserController] });
listen(app, 8080);No try/catch sits in findById. The thrown NotFoundError is caught by the filter runner, matched against NotFoundFilter's @Catch(NotFoundError), and turned into the controller's own 404 shape — a BadRequestError thrown from the same handler would not match and would fall straight through to the default error response instead.
How it works
Example — a class-level filter covering every route, and a method-level filter overriding it for one route:
import { Service, Catch, UseFilter, Controller, Post } from 'nextrush/class';
import type { ExceptionFilter } from 'nextrush/class';
import type { Context } from 'nextrush';
class PaymentDeclinedError extends Error {}
@Service()
@Catch() // no arguments — a catch-all
class GenericErrorFilter implements ExceptionFilter {
catch(_error: unknown, ctx: Context): void {
ctx.status = 500;
ctx.json({ error: 'Something went wrong' });
}
}
@Service()
@Catch(PaymentDeclinedError)
class PaymentDeclinedFilter implements ExceptionFilter {
catch(error: unknown, ctx: Context): void {
ctx.status = 402;
ctx.json({ error: 'Payment declined', reason: (error as Error).message });
}
}
@UseFilter(GenericErrorFilter) // covers every route on the controller
@Controller('/orders')
class OrderController {
@UseFilter(PaymentDeclinedFilter) // checked first, for this route only
@Post()
create(): never {
throw new PaymentDeclinedError('Card declined');
}
}Observation — create()'s thrown error is handled by PaymentDeclinedFilter, not GenericErrorFilter, even though the catch-all would also match. Any other route on OrderController with no method-level filter falls back to GenericErrorFilter for whatever it throws.
Explanation — a few rules from the filter runner produce that behavior:
@Catchdeclares the match,instanceofdecides it.@Catch(NotFoundError)matchesNotFoundErrorand its subclasses.@Catch()with no arguments — or no@Catchat all — makes the filter a catch-all that matches any thrown value, asGenericErrorFilterdoes above.- Method-level filters are checked before class-level filters. The runner builds one precedence list per route — its method-level filters first, then its class-level filters — and walks it in order.
PaymentDeclinedFilterwins overGenericErrorFilterbecause it's method-level, not because it was declared more specifically; a method-level catch-all would beat a class-level specific filter the same way. - The first match wins; later filters in the list never run for that error. Once a filter's
@Catchtypes match, itscatch(error, ctx)is called and no further filter in the list is consulted, even one that would also have matched. - An unmatched error is rethrown unchanged. If no attached filter's
@Catchtypes match, the error propagates exactly as it would with no filters attached at all — straight to the defaultHttpErrorhandling, with the same status and body it would otherwise produce. - Filters are resolved from the DI container. A filter class is instantiated through the same container as controllers and services, so its constructor can declare injected collaborators — a logger, an error-tracking client — exactly as a service can.
The exact metadata storage and precedence-list construction live in the @nextrush/class reference — a concept teaches the model, not every internal.
A filter that itself throws is not caught by another filter
If a filter's own catch() method throws, that error is not re-routed through the remaining
filters in the list — it propagates out of the filter runner the way any other uncaught error
would. Keep a filter's catch() implementation simple and defensive.
Typical use cases
Filters fit a controller or route that needs to answer one specific error type differently from the application's default:
- Domain-specific error shapes — a
resourceTypefield on a 404 in one controller, aninvoiceIdon the same error type in another. - Third-party or ORM error translation — catching a database driver's own error class and mapping it to your API's response shape, without teaching the global handler about that driver.
- Auditing a specific failure — a filter that logs or records an error (via an injected service) before producing the same response the default handler would have.
Reach for the default HttpError handling instead when every route can share one response shape — that's the common case, and filters are the exception, not the default.
Configuration
@UseFilter takes no options object — only a rest list of filter classes, and where you attach it is the configuration:
import { Service, Catch, UseFilter, Controller, Get } from 'nextrush/class';
import type { ExceptionFilter } from 'nextrush/class';
import type { Context } from 'nextrush';
class ConflictError extends Error {}
@Service()
@Catch(ConflictError)
class ConflictFilter implements ExceptionFilter {
catch(_error: unknown, ctx: Context): void {
ctx.status = 409;
ctx.json({ error: 'Conflict' });
}
}
@UseFilter(ConflictFilter) // covers every route on the controller
@Controller('/posts')
class PostController {
@Get()
list() {} // ConflictFilter applies, but list() never throws it in practice
@Get('/:id')
findById() {} // same coverage — class-level filters apply to every method
}@Catch(...) is the other configuration point: pass one or more error constructors to scope a filter, or omit them for a catch-all. There is no options surface for either decorator beyond these two — a filter's behavior is entirely in its catch() method body.
Performance
- Complexity — a route's filter precedence list is built once, when its handler is constructed, not re-read from metadata per request. On the success path a filtered route costs nothing extra; on a thrown error, matching walks the precedence list until the first
instanceofhit. - Memory — a singleton filter (
@Service()default) resolves to one shared instance across every request; the list itself is a small array shared by the route. - Scaling — filters only run on the error path, so they never enter the throughput budget of successful requests. Order filters most-specific-first within a single
@UseFilter(A, B)call when more than one could match the same error, since the first match wins. Measure withapps/benchmarkbefore tuning.
Security
A filter controls exactly what a matched error's response looks like, so what it exposes is entirely on the filter author.
- Re-introducing a leak the default handler prevents. Threat: a filter's
catch()writes the raw error message or a stack trace into the response body. Why: unlike the default handler, a filter has no built-inexposecheck — it controls the response directly. Safe default: mirror the default's exposed/non-exposed distinction — send a generic body for anything that isn't a deliberate client-facing error. Avoid: interpolating(error as Error).messageor.stackstraight intoctx.json(). - A catch-all filter hiding an unexpected error type. Threat:
@Catch()matches everything, including error types the filter author never considered. Why: a catch-all filter intercepts before the default handler ever sees the error, so its response — not the framework's — is what the client and your logs get. Safe default: scope@Catch(...)to the specific types you intend to handle; reserve catch-alls for a deliberate, audited fallback. Avoid: an unscoped@Catch()used as a shortcut instead of a considered decision. - Losing visibility into rethrown errors. Threat: an error a filter didn't match still needs to be logged or monitored the way the default handler logs it. Why: the default handling still runs for a rethrown error, so this isn't a gap — but a filter's matched errors bypass whatever logging the default handler does, unless the filter does its own. Safe default: inject a logger into a filter and log deliberately, the same way you would in a custom error handler. Avoid: assuming a matched error is still observed somewhere else.
A filter's catch() has no automatic expose protection — unlike the framework's default
HttpError handling, it is your code writing the response body. Treat a filter exactly like a
hand-written error handler for the types it declares, with the same discipline about what's
safe to send.
Trade-offs
Why a scoped, declared catch over a hand-rolled try/catch — it isolates a controller's domain-specific error shaping into a reusable, injectable unit, with the framework guaranteeing the fallthrough to default handling that hand-rolled code has to remember on its own.
- Benefits — no repeated
catch/instanceof/rethrow boilerplate per method; an unmatched error is guaranteed to fall through correctly, because the runner (not application code) does the rethrow; a filter is DI-resolved and unit-testable in isolation; the mapping is visible in the controller's decorators. - Costs — filters apply only to class-based controller routes, not functional routes; a filter's
catch()has no built-inexposesafety net, so leak-prevention is back on the author for whatever it handles; an overly broad catch-all can intercept more than intended. - Alternatives — the default
HttpErrorhandling for anything that can share one response shape across the app; a customerrorHandler()for an app-wide reshaping that still applies everywhere; a filter specifically when one controller or route needs a genuinely different shape for one error type. - Why NextRush chose this — scoping error handling the same way guards and interceptors scope their concerns keeps the class runtime consistent: declare the concern next to the controller it applies to, resolve it from the same DI container, and let anything undeclared fall through to the layer below.
Decision guide
Choose an exception filter when:
- ✓ One controller or route needs a response shape for a specific error type that differs from the application's default
- ✓ You want that mapping declared next to the controller, resolved from DI, and testable on its own
- ✓ You're translating a third-party or domain error into your API's shape without teaching the global handler about it
Reach for the default HttpError handling instead when:
- ✗ Every route can share one consistent response shape — that's the common case, and it needs zero filters
- ✗ You're changing app-wide behavior (logging,
includeStack, a global body shape) — that'serrorHandler()options, not a per-controller filter
Common mistakes
- Forgetting
@Catch(...)and getting an unintended catch-all. Why it happens: a filter class with no@Catchat all is treated exactly like@Catch()— a catch-all — which happens by accident more often than by design. Correct approach: always declare the specific error types a filter is meant for, unless a catch-all is the deliberate design. If ignored: the filter silently intercepts error types it was never written to handle. - Expecting filter order to matter for disjoint error types. Why it happens: order matters for which filter wins when several could match the same error. Correct approach: if two filters in one
@UseFilter(A, B)call handle different error types, only the one whose type actually matches ever runs — order is irrelevant between them. If ignored: you reorder filters trying to fix a bug that isn't about ordering at all. - Expecting a filter to catch errors from outside the controller method's execution. Why it happens: a filter feels like it wraps "the request," the way middleware does. Correct approach: know that a filter wraps guard evaluation, parameter resolution, and the method body for one route — not earlier middleware. If ignored: an error thrown before the controller resolves is expected to hit a filter that never sees it.
- Writing a filter that leaks error detail the default handler would have hidden. Why it happens: the default handler's
exposesafety net doesn't carry over automatically. Correct approach: apply the same exposed/non-exposed discipline insidecatch()that the default handler applies. If ignored: a filter reintroduces the exact leak the framework's default error handling was built to prevent.
Key takeaways
- A filter is a class that declares which errors it handles with
@Catch(...)and turns one into a response withcatch(error, ctx), attached via@UseFilter(...). @Catch(Type)matches viainstanceof(covering subclasses);@Catch()or no@Catchat all is a catch-all.- Method-level filters are checked before class-level filters; within the resulting list, the first filter whose type matches wins and no later filter runs for that error.
- An error no attached filter matches is rethrown unchanged, straight to the framework's default
HttpErrorhandling — filters narrow the default, they don't replace it. - Filters are resolved from the DI container, so a filter's constructor can inject services like a logger, exactly as a controller or service can.
- A filter's
catch()has no built-inexposesafety net — unlike the default handler, leak-prevention for what a filter'scatch()sends is on the filter author.
Continue learning
Errors
The default HttpError handling a filter narrows — and falls through to when nothing matches.
Guards
The access-control layer that runs before a filtered route's method body.
Interceptors
The layer that wraps a successful call — filters are the layer for a failed one.
Dependency injection & scopes
How a filter class gets its injected services and what its scope means.
Error handling guide
The task-oriented walkthrough for wiring both the default handler and scoped filters.
@nextrush/class reference
The full @Catch, @UseFilter, and ExceptionFilter signatures.
Interceptors
How NextRush wraps a controller method with code that runs before and after the handler — to time it, reshape its result, or return a cached value.
Application Lifecycle
OnInit and OnShutdown — duck-typed hooks that run once at boot and once at shutdown, distinct from the per-request lifecycle that runs on every request.