Errors

This page owns the server-side error story: the app error catalog, AppError, cause preservation, and unhandled-error mapping. For handling failed calls on the client with ContractError, see Client.

Use Error reporting and alerting for production exception capture and alerting. Error catalogs describe expected application failures; they are not a replacement for production incident reporting.

Error shape

Framework-owned errors use a standard envelope:

{
  "code": "VALIDATION_ERROR",
  "message": "Invalid request body",
  "details": {
    "contract": "todos.create",
    "method": "POST",
    "path": "/api/todos",
    "location": "body",
    "issues": [
      { "path": ["title"], "message": "Required" }
    ]
  }
}

Validation and response-contract diagnostics include the contract name, HTTP method, contract path, and failing location when Beignet can identify them. 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 });

Catalog errors can also be declared on a contract group with defineContractGroup().errors(...). Group errors merge with route-level .errors(...), so contracts carry the union of both; later declarations win when the same catalog key is declared twice.

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

Beignet does not automatically redact route-owned error details. Treat message and details as public client response data. Put stable IDs, field names, ability names, or operation names there; keep stack traces, provider errors, SQL, secrets, tokens, PHI, private content, and raw request bodies in cause, logs, or error reporting instead.

Throw AppError

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

import { defineRouteGroup } from "@beignet/next";
import type { AppContext } from "@/app-context";
import { useCase } from "@/lib/use-case";

export const getTodoUseCase = useCase
  .query("todos.get")
  .input(GetTodoInputSchema)
  .output(TodoSchema)
  .run(async ({ ctx, input }) => {
    const todo = await ctx.ports.todos.findById(input.id);
    if (!todo) {
      throw appError("TodoNotFound", { details: { id: input.id } });
    }

    return todo;
  });

export const todoRoutes = defineRouteGroup<AppContext>({
  name: "todos",
  routes: [{ contract: getTodo, useCase: getTodoUseCase }],
});

AppError instances thrown by a bound use case or 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,
    { operation: "todos.query" },
    undefined,
    { cause: dbError },
  );
}

Helper-created AppErrors support the same option:

throw appError("InternalServerError", {
  details: { operation: "todos.query" },
  cause: dbError,
});

The cause is available via error.cause and is never exposed to the client by Beignet. details and message are public response fields when the error crosses the HTTP boundary, so only include values that are safe for clients.

Observe and map unhandled errors

Use the server's onCaughtError option for logging, metrics, and tracing: it observes caught failures without changing response behavior. The server-level mapUnhandledError callback maps unknown or otherwise unhandled exceptions after declared AppError instances are auto-mapped.

onCaughtError: ({ err, req, ctx }) => {
  console.error("Caught error:", err);
  console.error("Request:", req.method, new URL(req.url).pathname);
  console.error("Request ID:", ctx?.requestId);
},
mapUnhandledError: ({ ctx }) => {
  return {
    status: 500,
    body: {
      code: "INTERNAL_SERVER_ERROR",
      message: "Internal server error",
      requestId: ctx?.requestId,
    },
  };
},

mapUnhandledError responses are framework-owned; see Request lifecycle for the ownership taxonomy.

Errors on the client

Catalog errors cross the HTTP boundary as the same { code, message, details?, requestId? } envelope, and framework-owned responses carry the x-beignet-error-owner: framework header so clients can tell them apart from route-owned errors with the same status. On the client they surface as ContractError, with isError(...) narrowing by catalog code, status, or source, and safeCall() for result-style handling. See Client for the full client-side story.

Map errors to UI

Use contractErrorMessage from @beignet/core/client to turn a failed call into user-facing copy. Non-ContractError values return the fallback, client-side input validation failures return a generic "check the highlighted fields" message, and catalog codes can override copy per call site:

import { contractErrorMessage } from "@beignet/core/client";

const message = contractErrorMessage(error, "Could not update profile.", {
  HANDLE_UNAVAILABLE: "That handle is already taken.",
});

For React Hook Form, rootFormError from @beignet/react-hook-form wraps the same mapping in the form.setError("root", ...) shape; see React Hook Form. Use catalog codes for product-specific copy while keeping the default framework message for ordinary route-owned errors.