Error Handling
Throw an error anywhere in your stack — a handler, a repository, a domain object — and Heximon turns it
into a clean, machine-readable response. Throw a built-in NotFoundError and the caller gets a 404;
throw a custom error and an ErrorFilter you wrote shapes the body. The handler stays thin: it states
what went wrong and throws, and the response layer owns how that looks on the wire.
import { UserNotFoundError } from "./user-errors";
public async getById(id: string): Promise<User> {
const user = this.usersById.get(id);
if (user === undefined) throw new UserNotFoundError(id); // surfaces as a 404 problem+json
return user;
}
Throw a built-in HeximonError
HeximonError is the transport-neutral base for every framework error. It carries an HTTP statusCode,
a statusMessage, an errorName, optional data, the optional why / fix / link guidance (below),
and a toJSON() — so any subclass already knows how to become a response. The common cases ship as concrete
subclasses you import from @heximon/runtime/errors and throw directly:
| Error | Status | Throw it when |
|---|---|---|
NotFoundError | 404 | A resource does not exist. |
UnauthorizedError | 401 | Authentication is missing or invalid. |
ForbiddenError | 403 | The caller is authenticated but not permitted. |
ConflictError | 409 | The request conflicts with current state. |
ConcurrencyError | 409 | An optimistic-concurrency version check failed. |
ValidationError | 400 | Input failed validation (carries data.issues). |
HeximonError | 500 | The base — subclass it for anything custom. |
import { ConflictError, NotFoundError } from "@heximon/runtime/errors";
throw new NotFoundError("User not found");
throw new ConflictError("That email is already in use.");
No filter is required for these. When one reaches the boundary unhandled, Heximon formats it into the
standard envelope automatically — a built-in NotFoundError is already a 404.
HeximonError.data is for problem-specific extension fields, so it may not reuse the five reserved
envelope keys — type, status, title, detail, instance. The type rejects them, because those
slots belong to the envelope itself and your extension data rides alongside, never overwrites it.Define a domain error
A built-in error is enough when the status code says it all. When you want to carry context the response
should expose — the id that was missing, the email that collided — define a plain domain Error. It does
not need to extend HeximonError; an ErrorFilter can catch any Error subclass, so your domain layer
stays free of HTTP concerns.
export class UserNotFoundError extends Error {
public readonly userId: string;
public constructor(userId: string) {
super(`No user exists with id '${userId}'.`);
this.name = "UserNotFoundError";
this.userId = userId;
}
}
The repository throws this the moment a lookup misses. The controller above never checks for the miss and
never builds a response — it just calls getById. Keeping the persistence concern in the repository is
the point: the HTTP layer stays thin, and the same error works no matter which transport invoked it.
Map an error with a filter
An ErrorFilter is the class that turns a specific error into a specific response. Declare
implements ErrorFilter<SomeError> — the type argument is the error class this filter catches, and its
subclasses — then implement catch(error, action) and return the Response to send. (extends ErrorFilter<SomeError> binds the same way; use it when the filter sits on your own base class.)
Heximon
reads that type at compile time and bakes the error class into the route descriptor, so when a handler
throws it dispatches to the first filter whose error matches by instanceof. A filter for a base error
therefore also catches everything that extends it.
import { type ErrorFilter, type HttpAction, HttpResponse } from "@heximon/http";
import { UserNotFoundError } from "./user-errors";
export class UserNotFoundErrorFilter implements ErrorFilter<UserNotFoundError> {
public catch(error: UserNotFoundError, action: HttpAction): Response {
const { pathname } = new URL(action.request.url);
return HttpResponse.json(
{
type: "https://example.com/problems/user-not-found",
status: 404,
title: "User Not Found",
detail: error.message,
instance: pathname,
userId: error.userId, // problem-specific extension data
},
{ status: 404, headers: { "content-type": "application/problem+json" } },
);
}
}
ErrorFilter lives in @heximon/http, alongside Middleware, because a filter's catch receives
the route's HttpAction — it carries action.request, action.scopes, and the typed response builders.
Attach a filter to a controller
A filter is wired in two places, and that split is deliberate: listing it in providers makes it part of
the construction graph, so the runtime resolves it by identity like any other class; naming it on the
controller's options says where it applies — these routes. The same filter can guard one controller
or be named module-wide under http: { errorFilters }.
import type { Controller, Get, Post } from "@heximon/http";
import { EmailAlreadyInUseErrorFilter, UserNotFoundErrorFilter } from "./user-error.filter";
import { CreateUser } from "./user.schema";
import { type User, UsersRepository } from "./users.repository";
export class UsersController implements Controller<{
prefix: "/users";
errorFilters: [UserNotFoundErrorFilter, EmailAlreadyInUseErrorFilter];
}> {
public constructor(private readonly users: UsersRepository) {}
// The handler just throws UserNotFoundError; the filter maps it to a 404.
public async get(action: Get<"/:id">): Promise<User> {
return this.users.getById(action.request.pathParams.id);
}
public async create(action: Post<"/", { body: CreateUser }>): Promise<User> {
const body = await action.request.readValidatedBody();
return this.users.create(body); // duplicate email throws → 409 filter
}
}
import { Module } from "@heximon/runtime";
import { EmailAlreadyInUseErrorFilter, UserNotFoundErrorFilter } from "./user-error.filter";
import { UsersController } from "./users.controller";
import { UsersRepository } from "./users.repository";
export class UsersModule extends Module({
providers: [UsersRepository, UserNotFoundErrorFilter, EmailAlreadyInUseErrorFilter],
http: { controllers: [UsersController] },
}) {}
When several filters can apply, the most specific one wins. Your filters run first; behind them, as the
final fallback, the error envelope maps any unhandled HeximonError to its problem+json automatically —
so a thrown NotFoundError is a real 404, never an opaque 500.
Validation errors
(RequestValidationError, a 400) and body-size errors (BodySizeExceededError, a 413) flow through
the same envelope; no bespoke filter is required for them.
Filters can inject dependencies. When the dispatcher needs a filter instance it resolves it through
app.get(token) first, so a filter listed in providers receives its constructor dependencies exactly as
any other class does. A filter that is not in providers (a zero-arg filter) is constructed with
new token() as a fallback, keeping backward compatibility:
import { type ErrorFilter, type HttpAction, HttpResponse } from "@heximon/http";
import { Logger } from "@heximon/runtime";
import { UserNotFoundError } from "./user-errors";
export class UserNotFoundErrorFilter implements ErrorFilter<UserNotFoundError> {
constructor(private readonly logger: Logger) {}
public catch(error: UserNotFoundError, action: HttpAction): Response {
this.logger.warn("User not found:", { userId: error.userId });
return HttpResponse.json({ status: 404 }, { status: 404 });
}
}
List both the filter and any deps in providers — the same entry that grants the class DI resolution:
import { Module } from "@heximon/runtime";
import { Logger } from "@heximon/runtime";
import { UserNotFoundErrorFilter } from "./user-error.filter";
export class UsersModule extends Module({
providers: [Logger, UserNotFoundErrorFilter], // Logger injected into the filter via DI
http: { controllers: [UsersController] },
}) {}
Translate a domain error into a validation error
A broken invariant often maps cleanly onto client-fixable input — a bad slug is really a bad field. Catch
the domain error and rethrow it as a ValidationError, and the client gets a field-level pointer it can
highlight, instead of an opaque 500.
import { ValidationError } from "@heximon/runtime/errors";
import type { ErrorFilter } from "@heximon/http";
import { InvalidSlugError } from "./errors";
export class InvalidSlugErrorFilter implements ErrorFilter<InvalidSlugError> {
public catch(error: InvalidSlugError): never {
throw new ValidationError({
message: "The provided slug is invalid.",
issues: [
{
detail: error.message,
pointer: "#/slug", // a JSON Pointer to the offending field
location: "body", // body | query | header | path
},
],
});
}
}
Each issue carries a pointer to the field and an optional location telling the client which part of
the request the pointer applies to. One domain error can surface several field-level problems in a single
issues array — that's how you turn a domain failure into a form the client can act on.
The problem+json envelope
When no filter matches, Heximon renders the error into a standard, machine-readable error shape — RFC 9457
application/problem+json: a type URI, the status, a title, a detail, an instance, plus any
extension data. Clients parse one shape across every endpoint instead of guessing per-route error bodies.
{
"type": "tag:api.example.com,2026-01-15:UserNotFoundError",
"status": 404,
"title": "User Not Found",
"detail": "No user exists with id '42'.",
"instance": "/users/42",
"userId": "42"
}
For server (5xx) errors, detail and data are omitted so internals never leak to a caller; client
(4xx) errors keep them, because a client error is one the caller can fix and the detail is how they fix
it.
An unmatched route also returns an RFC 9457 404 application/problem+json — the same envelope shape, not
a bare { status, message }.
Declare the error responses a route returns
A contract's .responses() map is the machine-readable statement of what a route sends back on error.
Declare it alongside the success status:
import { Contract, Route } from "@heximon/contract";
import { NotFoundError } from "@heximon/runtime/errors";
import { SchemaObject } from "@heximon/schema";
import * as v from "valibot";
class IdParams extends SchemaObject({ id: v.string() }) {}
class UserResponse extends SchemaObject({ id: v.string(), name: v.string() }) {}
class UsersApi extends Contract({
prefix: "/users",
routes: {
find: Route.get("/:id")
.pathParams(IdParams)
.responses({ 200: UserResponse, 404: NotFoundError.schema }),
},
}) {}
This is why the map matters: the contract tells the FE client which error bodies to expect per status, and
@heximon/openapi reflects the same map into the generated OpenAPI document — no out-of-band annotation
required.
Shipped error classes carry a .schema property you can drop directly into the map. For example,
NotFoundError.schema is an ErrorSchema<404, "Not Found"> derived from its static fields:
import { type ErrorSchema, ErrorSchemaBuilder } from "@heximon/schema";
import { HeximonError } from "./heximon-error";
export class NotFoundError extends HeximonError {
public static override readonly errorName: string = "NotFoundError";
public static override readonly statusCode = 404;
public static override readonly statusMessage = "Not Found";
// Derived from the static fields above — drop `NotFoundError.schema` into a route's `.responses()`.
public static readonly schema: ErrorSchema<404, "Not Found"> =
ErrorSchemaBuilder.create(NotFoundError);
}
For a custom domain error, call ErrorSchemaBuilder.create(YourErrorClass) with the same static-field
shape to produce a matching schema you can drop into .responses().
Override the error envelope
The default envelope (Rfc9457ErrorEnvelope) renders every HeximonError as application/problem+json; bind
a custom ErrorEnvelope subclass at the root module to reshape every error response at once — a legacy API
that must emit { error, code, message }, for instance.
See Advanced DI for the full subclass and the runtime-only caveat it carries for the contract/OpenAPI surface.
Guide the caller with why / fix / link
Beyond detail, a HeximonError can carry three optional pieces of author-curated guidance: why (what
happened), fix (the next step), and link (documentation). Pass them as constructor options:
throw new HeximonError("Payment provider timed out.", {
why: "The upstream payment provider did not respond within 5s.",
fix: "Retry the request in a few seconds; if it persists, check the provider status page.",
link: "https://docs.example.com/errors/payment-timeout",
});
They surface as RFC 9457 extension members alongside the envelope:
{
"type": "tag:api.example.com,2026-01-01:InternalServerError",
"status": 500,
"title": "Internal Server Error",
"instance": "/checkout",
"why": "The upstream payment provider did not respond within 5s.",
"fix": "Retry the request in a few seconds; if it persists, check the provider status page.",
"link": "https://docs.example.com/errors/payment-timeout"
}
Unlike detail and data, this guidance is emitted for every status, including 5xx — it is text you
wrote on purpose to help the caller, not auto-captured internals, so it is safe to surface. (It cannot collide
with the five reserved keys, and a curated value wins over any same-named data field.)
An unexpected error is still a clean 500
What about an error that isn't a HeximonError and no filter catches — a genuine bug, a thrown runtime
Error? It does not escape as an opaque, framework-specific 500.
The dispatcher logs it through its own
RequestDispatcher-tagged logger (a structured line, not a raw stack dump to stdout) and returns the same
RFC 9457 envelope as everything else — a generic 500 Internal Server Error with detail/data omitted, so
the caller gets one consistent error shape and nothing internal leaks:
{
"type": "tag:api.example.com,2026-01-01:InternalServerError",
"status": 500,
"title": "Internal Server Error",
"instance": "/orders/42",
"requestId": "01J…"
}
The full error — message and stack — rides the log and the requestId correlates it to this response, so you
keep every diagnostic while the client sees nothing it shouldn't.
See also
- Modules — declare filter providers and name them under the
httpnamespace. - Request Context — read the request that triggered an error through the ambient
Context. - Application Lifecycle — where filters sit in the boot and dispatch flow.
- Validation — the built-in
400 problem+jsonthat rejects an invalid body before your handler runs. - the ladder's L02 — HTTP validation
— a repository that throws
UserNotFoundError extends NotFoundErrorandEmailAlreadyInUseError extends ConflictError— no customErrorFilterneeded, since extending a built-in error class is enough for the built-in formatter to render404/409problem+json— and an end-to-end test that asserts both bodies.
Permissions & scopes
Declare per-route scopes with Route.scopes, enforce them with GuardMiddleware, and run manual checks through AuthContext.isAllowed on inline routes.
Testing a Heximon App
Unit-test providers and handlers as plain classes with constructor-injected fakes, then reach for createTestApp and createTestClient when you need the real compiled wiring — routes, middleware, validation, error filters, and bus dispatch.