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: by default, 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.Error(): 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(
StatusCode.BadRequest,
"Email already in use",
"users.create",
);

Built-in subclasses

ClassWhen to throwDefault status
AppErrorAnything500 Internal Server Error
NotFoundErrorMissing entity / 404404 Not Found
ValidationErrorClassValidation failed (multi-error)400 Bad Request
import { NotFoundError } from "@expressots/core";

const user = await this.userRepo.findById(id);
if (!user) throw new NotFoundError(`User ${id} not found`, "users.get");

ValidationErrorClass carries a list of ValidationError items and is thrown automatically by @validatedBody / @validatedQuery etc. when payload validation fails.

RFC 7807 problem details

By default the global exception filter turns any AppError (or subclass) into a problem-details JSON response:

HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
"type": "https://expresso-ts.com/errors/not-found",
"title": "Resource not found",
"status": 404,
"detail": "User u_xyz123 not found",
"instance": "/api/users/u_xyz123"
}

For ValidationErrorClass, the payload also includes a typed errors array:

{
"type": "https://expresso-ts.com/errors/validation",
"title": "Validation failed",
"status": 400,
"detail": "Request body did not pass validation",
"instance": "/api/users",
"errors": [
{ "field": "email", "code": "isEmail", "message": "must be a valid email" },
{ "field": "password", "code": "minLength", "message": "must be at least 8 characters" }
]
}

Disable RFC 7807 (e.g. when you need legacy {statusCode, error} shape) by registering a custom global filter: see Exception filters.

The Report helper

Report.Error() is the ergonomic v3 helper, still supported in v4. It throws an AppError with 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?) returns the error so you can throw it on the same line. Internally it constructs an AppError, which means the same RFC 7807 + filter pipeline applies.

Wiring the global error handler

In v4, the global exception filter is registered automatically by the framework. You don't need to call setErrorHandler() in configureServices() anymore. Custom filters are registered with the @Catch() decorator: see Exception filters.

If you only want to toggle stack-trace verbosity (development vs production), set the LOG_LEVEL env var or call Logger.configure({ level: "DEBUG" }) before bootstrap. Stack traces are emitted at DEBUG and INFO, hidden at WARN and above.

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(StatusCode.Conflict, "User already exists", "users.create");
}
throw err;
}
}

See also

  • Exception filters: @Catch, @UseFilters, custom global filters, removing RFC 7807.
  • Status codes: the StatusCode enum used everywhere.
  • Validation: how ValidationErrorClass is produced and the error format.
  • Logging: structured error logs and redaction.