Error handling
ExpressoTS v4 ships a layered error story:
- Typed errors:
AppError,NotFoundError,ValidationErrorClassextend a single base so every layer of your app speaks the same language. - RFC 7807 problem details: with exception filters enabled, errors are serialized as
application/problem+jsonso HTTP clients can introspect them. - Exception filters: pluggable handlers (
@Catch(...)) for full control over how a specific error class becomes an HTTP response. See Exception filters for the focused reference. Report: the v3 helper is still available for ergonomic throws.
This page covers (1), (2) and (4); the Exception filters page covers (3).
Typed errors
AppError
AppError is the base for every ExpressoTS error. It carries a status code, a developer-facing detail, and an optional service tag for log correlation:
import { AppError, StatusCode } from "@expressots/core";
throw new AppError(
"Email already in use",
StatusCode.BadRequest,
"users.create",
);
Built-in subclasses
| Class | When to throw | Default status |
|---|---|---|
AppError | Anything | 500 Internal Server Error |
NotFoundError | Missing entity / 404 | 404 Not Found |
ValidationErrorClass | Validation failed (multi-error) | 422 Unprocessable Entity |
NotFoundError takes a resource name and an optional id; the message is built for you:
import { NotFoundError } from "@expressots/core";
const user = await this.userRepo.findById(id);
if (!user) throw new NotFoundError("User", id); // "User with id ... not found"
ValidationErrorClass carries a list of ValidationError items ({ property, messages, value? }). Note that the request validation decorators (@validatedBody / @validatedQuery etc.) do not throw it; they answer invalid requests directly with a plain JSON 400 (see Validation). Throw ValidationErrorClass yourself when you want a validation failure to go through the exception-filter pipeline.
RFC 7807 problem details
With exception filters enabled (see Wiring the exception-filter pipeline below), the built-in filters turn any AppError (or subclass) into a problem-details JSON response. BaseExceptionFilter.sendErrorResponse sets Content-Type: application/problem+json on these responses, unless a filter set its own Content-Type first:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://expressots.dev/errors/not-found",
"title": "User with id u_xyz123 not found",
"status": 404,
"instance": "/api/users/u_xyz123",
"timestamp": "2026-06-10T18:45:00.000Z",
"detail": { "resource": "User", "id": "u_xyz123" }
}
For a thrown ValidationErrorClass, the built-in ValidationErrorFilter responds with status 400 and a validationErrors array:
{
"type": "https://expressots.dev/errors/validation-failed",
"title": "Validation Failed",
"status": 400,
"instance": "/api/users",
"timestamp": "2026-06-10T18:45:00.000Z",
"validationErrors": [
{ "property": "email", "messages": ["must be a valid email"] },
{ "property": "password", "messages": ["must be at least 8 characters"], "value": "short" }
]
}
By contrast, the 400 responses produced by the request validation decorators (@validatedBody etc.) are plain application/json, not application/problem+json; see Validation.
Need a different shape? Register a custom filter that sets its own body (and, if needed, its own Content-Type before calling sendErrorResponse): see Exception filters.
The Report helper
Report is the ergonomic v3 helper, still supported in v4. Its error() method builds an AppError in one call, ideal for use cases that want to fail fast without new-ing an error class:
import { provide, Report, StatusCode } from "@expressots/core";
@provide(CreateUserUseCase)
export class CreateUserUseCase {
constructor(
private readonly users: UserRepository,
private readonly report: Report,
) {}
async execute(data: ICreateUserRequestDTO): Promise<ICreateUserResponseDTO> {
const { name, email } = data;
if (await this.users.findByEmail(email)) {
throw this.report.error(
"User already exists",
StatusCode.BadRequest,
"users.create",
);
}
const user = await this.users.create(new User(name, email));
return { id: user.id, name: user.name, email: user.email, status: "success" };
}
}
report.error(message, statusCode?, service?, options?) returns the error so you can throw it on the same line. Internally it constructs an AppError, which means the same filter pipeline applies. Shortcut methods are available too: report.badRequest(), report.notFound(), report.unauthorized(), report.forbidden(), report.conflict(), report.validationFailed(), and report.internalServerError().
Wiring the exception-filter pipeline
Exception filters are opt-in. Enable them once in your App class:
import { AppExpress } from "@expressots/adapter-express";
export class App extends AppExpress {
async configureServices(): Promise<void> {
this.Middleware.setErrorHandler({
enableExceptionFilters: true,
showStackTrace: await this.isDevelopment(),
});
}
}
With enableExceptionFilters: true, every imported class decorated with @Catch() is auto-discovered, including the built-in AppErrorFilter, NotFoundFilter, ValidationErrorFilter, and GlobalExceptionFilter shipped by @expressots/core. Custom filters are registered with the @Catch() decorator: see Exception filters.
Calling setErrorHandler() with no options (or omitting enableExceptionFilters) installs a minimal default handler that responds with { "code": <status>, "error": "<message>" } as plain JSON.
showStackTrace controls whether stack traces are written to the logs. GlobalExceptionFilter additionally includes the stack in the response body when NODE_ENV is development or test.
Try / catch around async work
async / await errors propagate naturally. You don't need any try/catch unless you want to transform the error before re-throwing:
async execute(data: ICreateUserRequestDTO) {
try {
return await this.users.create(...);
} catch (err) {
// Transform a third-party DB error into a domain error.
if (isUniqueConstraintError(err)) {
throw new AppError("User already exists", StatusCode.Conflict, "users.create");
}
throw err;
}
}
See also
- Exception filters:
@Catch,@UseFilters, custom global filters, removing RFC 7807. - Status codes: the
StatusCodeenum used everywhere. - Validation: request validation and its plain JSON 400 error format.
- Logging: structured error logs and redaction.