Skip to main content
Version: 4.0.0-preview

Error handling

ExpressoTS v4 ships a layered error story:

  1. Typed errors: AppError, NotFoundError, ValidationErrorClass extend a single base so every layer of your app speaks the same language.
  2. RFC 7807 problem details: with exception filters enabled, errors are serialized as application/problem+json so HTTP clients can introspect them.
  3. 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.
  4. 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

ClassWhen to throwDefault status
AppErrorAnything500 Internal Server Error
NotFoundErrorMissing entity / 404404 Not Found
ValidationErrorClassValidation 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 StatusCode enum used everywhere.
  • Validation: request validation and its plain JSON 400 error format.
  • Logging: structured error logs and redaction.