Errors

Beignet has two complementary error surfaces:

Error shape

Framework-owned errors use a standard envelope:

{
  "code": "VALIDATION_ERROR",
  "message": "Invalid request body",
  "details": {
    "issues": [
      { "path": ["title"], "message": "Required" }
    ]
  }
}

Some framework-owned errors may also include a top-level requestId when your server context exposes one. Route-owned error responses can use any schema declared in contract.responses. Using { code, message, details? } for route-owned errors keeps your application errors consistent with framework errors.

Define an error catalog

import { createAppError, defineErrors } from "@beignet/core/errors";
import { z } from "zod";

export const errors = defineErrors({
  TodoNotFound: {
    code: "TODO_NOT_FOUND",
    status: 404,
    message: "Todo not found",
    details: z.object({ id: z.string() }),
  },
  Unauthorized: {
    code: "UNAUTHORIZED",
    status: 401,
    message: "You must be signed in",
  },
  Forbidden: {
    code: "FORBIDDEN",
    status: 403,
    message: "You cannot perform this action",
  },
});

export const appError = createAppError(errors);

Declare catalog errors on contracts with .errors(). Beignet maps them to the standard { code, message, details?, requestId? } response envelope automatically:

export const getTodo = todos
  .get("/api/todos/:id")
  .responses({ 200: TodoSchema })
  .errors({ TodoNotFound: errors.TodoNotFound });

The optional details schema types appError() calls and client-side error.details after narrowing by catalog code.

Throw AppError

Throw AppError from handlers, use cases, or domain/core/application code when you want a typed HTTP failure.

export const GET = server.route(getTodo).handle(async ({ ctx, path }) => {
  const todo = await ctx.ports.todos.findById(path.id);
  if (!todo) {
    throw appError("TodoNotFound", { details: { id: path.id } });
  }

  return { status: 200, body: todo };
});

AppError instances thrown by a route handler are route-owned. If the route declares response schemas, the generated response must match the schema for that status.

Preserve causes

Use cause for debugging without exposing internal errors to clients.

import { AppError, httpErrors } from "@beignet/core/errors";

try {
  await db.query(...);
} catch (dbError) {
  throw new AppError(
    httpErrors.InternalServerError,
    { table: "todos" },
    "Database query failed",
    { cause: dbError },
  );
}

Helper-created AppErrors support the same option:

throw appError("InternalServerError", {
  message: "Database query failed",
  details: { table: "todos" },
  cause: dbError,
});

Handle client errors

call() throws ContractError for non-2xx responses and local client failures.

const getTodoEndpoint = apiClient.endpoint(getTodo);

try {
  await getTodoEndpoint.call({ path: { id: "missing" } });
} catch (error) {
  if (getTodoEndpoint.isError(error, { code: "TODO_NOT_FOUND" })) {
    console.log(error.details.id);
  } else if (getTodoEndpoint.isError(error, { source: "client" }) && error.hasCode("VALIDATION_ERROR")) {
    console.log(error.details);
  }
}

ContractError.source is "http" for non-2xx server responses, "client" for local request validation, "network" for failed fetches, and "contract" for malformed or contract-invalid responses.

For declared route-owned error responses, error.body is the parsed response body. For framework-owned errors, error.body uses the standard envelope when the response includes x-beignet-error-owner: framework. error.details is the nested details value from that envelope or local validation details.

If a response does not match the declared route error schema and does not include Beignet's ownership header, the client reports a contract error instead of guessing ownership.

Use safeCall

Use safeCall() when explicit result handling reads better than exceptions.

const result = await getTodoEndpoint.safeCall({ path: { id: "missing" } });

if (result.ok) {
  console.log(result.data);
} else if (getTodoEndpoint.isError(result.error, { status: 404, source: "http" })) {
  console.log(result.error.body);
}

React Query integration uses call() because TanStack Query already models failures through its error channel.