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: by default, 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.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
| Class | When to throw | Default status |
|---|---|---|
AppError | Anything | 500 Internal Server Error |
NotFoundError | Missing entity / 404 | 404 Not Found |
ValidationErrorClass | Validation 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
StatusCodeenum used everywhere. - Validation: how
ValidationErrorClassis produced and the error format. - Logging: structured error logs and redaction.