Heximon Logo
Essentials

Error Handling

Throw an error and get a clean response — the HeximonError family, ErrorFilter mapping, and the RFC 9457 problem+json envelope.

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.

users.repository.ts
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:

ErrorStatusThrow it when
NotFoundError404A resource does not exist.
UnauthorizedError401Authentication is missing or invalid.
ForbiddenError403The caller is authenticated but not permitted.
ConflictError409The request conflicts with current state.
ConcurrencyError409An optimistic-concurrency version check failed.
ValidationError400Input failed validation (carries data.issues).
HeximonError500The base — subclass it for anything custom.
from a repository or service
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.

user-errors.ts
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.

user-error.filter.ts
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 }.

users.controller.ts
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
  }
}
users.module.ts
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:

an error filter that injects a Logger
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:

users.module.ts
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.

invalid-slug.filter.ts
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.

a 404 response
{
  "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:

users.api.ts
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:

packages/kernel/runtime/src/errors/not-found.ts (excerpt)
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.

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:

an unexpected 500
{
  "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 http namespace.
  • 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+json that rejects an invalid body before your handler runs.
  • the ladder's L02 — HTTP validation — a repository that throws UserNotFoundError extends NotFoundError and EmailAlreadyInUseError extends ConflictError — no custom ErrorFilter needed, since extending a built-in error class is enough for the built-in formatter to render 404/409 problem+json — and an end-to-end test that asserts both bodies.
Copyright © 2026