Errors
Beignet has two complementary error surfaces:
- Server-side
AppErrorfor structured application failures - Client-side
ContractErrorfor failed HTTP calls, validation failures, malformed responses, and network failures
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.