Exception Filters
The exception filter pipeline is how ExpressoTS v4 turns thrown Errors into HTTP responses. Enable it once with setErrorHandler({ enableExceptionFilters: true }) and the framework discovers a stack of built-in filters that produce RFC 7807 problem details. When you need to override that behaviour for one error type, one route, or globally, @Catch + BaseExceptionFilter + @UseFilters are the only three primitives you'll touch.
If you only care about which errors to throw, see Error handling. This page focuses on building filters.
The built-in stack
Exception filters are enabled in your App class:
import { AppExpress } from "@expressots/adapter-express";
export class App extends AppExpress {
async configureServices(): Promise<void> {
this.Middleware.setErrorHandler({ enableExceptionFilters: true });
}
}
Once enabled, the framework auto-discovers every imported class decorated with @Catch(...) and registers it in the global ExceptionFilterRegistry. @expressots/core ships four built-in filters that are discovered the same way:
| Filter | Catches | Status | Output |
|---|---|---|---|
ValidationErrorFilter | ValidationErrorClass | 400 | problem details with validationErrors[] |
NotFoundFilter | NotFoundError | 404 | problem details |
AppErrorFilter | AppError (and subclasses not caught above) | appError.statusCode | appError.toProblemDetails() |
GlobalExceptionFilter | everything else | 500 | problem details (type, title, status, instance, timestamp); stack included when NODE_ENV is development or test |
All of them respond through BaseExceptionFilter.sendErrorResponse, which sets Content-Type: application/problem+json unless the filter set its own Content-Type first.
This means you can ship a v4 app without writing a single filter: once exception filters are enabled, every throw new AppError(...) or throw new ValidationErrorClass(...) becomes a structured response automatically.
Anatomy of a filter
A custom filter is just a class that extends BaseExceptionFilter and is decorated with @Catch(...ErrorTypes):
import {
Catch,
BaseExceptionFilter,
ExceptionContext,
StatusCode,
provide,
} from "@expressots/core";
import { DatabaseError } from "../errors/database-error";
@provide(DatabaseErrorFilter)
@Catch(DatabaseError)
export class DatabaseErrorFilter extends BaseExceptionFilter {
catch(exception: DatabaseError, context: ExceptionContext): void {
this.logError(exception, context);
const status =
exception.code === "DUPLICATE_KEY"
? StatusCode.Conflict
: StatusCode.InternalServerError;
this.sendErrorResponse(context, status, {
type: "https://api.example.com/errors/database",
title: "Database error",
status,
detail: exception.message,
instance: context.request.path,
code: exception.code,
});
}
}
@Catch() accepts:
- one or more error classes: the filter catches only those types (and subclasses)
- no arguments: the filter becomes a catch-all, like
GlobalExceptionFilter
BaseExceptionFilter injects two helpers for free:
this.logger: the frameworkLoggerthis.report: the Report helper, if you need to build a typed payload
…and exposes two protected methods:
logError(exception, context): logs the error with controller/handler context, suggestions, and an optional stack tracesendErrorResponse(context, statusCode, body): safely writes the response (skips if headers are already sent) and tags itContent-Type: application/problem+json, unless you set a Content-Type on the response yourself before calling it
Activating a filter
Filters are activated by discovery, not by manual registration. With enableExceptionFilters: true set (see above), any class decorated with @Catch(...) is picked up at boot, as long as the file is imported somewhere in your dependency graph (e.g. via a controller import or index.ts re-export). Decorating it with @provide(...) lets the registry resolve it from the DI container; without it, the filter is instantiated directly and the logger is injected manually.
A common pattern is to keep them all under src/filters/ and barrel-export from src/filters/index.ts:
export * from "./database-error.filter";
export * from "./tenant-error.filter";
export * from "./fallback.filter";
Then make sure src/filters/index.ts is imported once, typically from your App class or main.ts.
Scoping filters with @UseFilters
By default a filter applies wherever its @Catch types match. To restrict a filter to one controller or one method only, use @UseFilters:
import { provide } from "@expressots/core";
import { UseFilters } from "@expressots/core";
import { controller, Get, Post } from "@expressots/adapter-express";
import { TenantErrorFilter } from "../filters/tenant-error.filter";
import { AdminErrorFilter } from "../filters/admin-error.filter";
@provide(UsersController)
@controller("/users")
@UseFilters(TenantErrorFilter) // Applies to the whole controller
export class UsersController {
@Get("/")
list() { /* … */ }
@Post("/admin")
@UseFilters(AdminErrorFilter) // Method-level filter (runs first)
adminAction() { /* … */ }
}
The resolution order is:
- Method-level filters (from
@UseFilterson the method); these run for any error thrown on that route - Controller-level filters (from
@UseFilterson the controller); same, for any error in that controller - Global filters (from
@Catchdiscovery), matched by exception type walking up the inheritance chain (exact type first, then parent classes, then catch-all filters)
Filters run in that order; the first one that sends a response stops the chain. If no filter sends a response, a fallback handler answers with { "code": <status>, "error": "<message>" }.
Catching everything
A catch-all filter is built like this:
import { Catch, BaseExceptionFilter, ExceptionContext, StatusCode, provide } from "@expressots/core";
@provide(FallbackFilter)
@Catch() // <- no arguments
export class FallbackFilter extends BaseExceptionFilter {
catch(exception: Error, context: ExceptionContext): void {
this.logError(exception, context);
this.sendErrorResponse(context, StatusCode.InternalServerError, {
type: "https://api.example.com/errors/internal",
title: "Internal server error",
status: StatusCode.InternalServerError,
detail: process.env.NODE_ENV === "production"
? "Something went wrong"
: exception.message,
instance: context.request.path,
});
}
}
You usually don't need this. GlobalExceptionFilter already provides a sensible default. Define your own catch-all only when you need to:
- Send errors to a third-party tracker (Sentry, Datadog, …)
- Add tenant/correlation IDs to every response
- Mask error messages in production
The ExceptionContext
Every filter receives a structured ExceptionContext:
interface ExceptionContext {
request: Request;
response: Response;
next: NextFunction;
controller?: NewableFunction; // controller class (if resolved)
handler?: string; // controller method name
route?: string; // route path
method?: string; // GET, POST, …
httpContext?: IHttpContext; // adapter HTTP context (if available)
showStackTrace?: boolean; // honoured by `logError`
}
You can use context.request / context.response exactly like you would in raw Express, including reading custom headers like x-correlation-id to attach to the response payload.
Testing a filter
Filters are plain classes, so the easiest path is to construct one with mocked Logger / Report and call catch():
import { DatabaseErrorFilter } from "../src/filters/database-error.filter";
import { DatabaseError } from "../src/errors/database-error";
describe("DatabaseErrorFilter", () => {
it("returns 409 for duplicate key", () => {
const filter = new DatabaseErrorFilter();
const status = jest.fn().mockReturnThis();
const json = jest.fn();
filter.catch(new DatabaseError("dup", "DUPLICATE_KEY"), {
request: { path: "/users" } as never,
response: { headersSent: false, status, json } as never,
next: jest.fn(),
method: "POST",
});
expect(status).toHaveBeenCalledWith(409);
expect(json).toHaveBeenCalledWith(expect.objectContaining({ status: 409 }));
});
});
For full HTTP-level integration tests, see Testing.
See also
- Error handling:
AppError,Report, and the v4 error model. - Validation: request validation; its 400 responses are sent directly as plain JSON, not through filters.
- Decorators: quick reference for
@Catchand@UseFilters. - Status codes: the
StatusCodeenum used in filters.