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.