Skip to main content
Version: 4.x

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.