Client
The client gives you a fully typed HTTP client derived from your contracts. No code generation — just TypeScript inference.
Use the client directly in scripts, tests, Server Components, and non-React code. In React UI, you usually consume the same endpoints through React Query, which wraps this client and inherits its error semantics.
Creating a client
The examples below use the Todos contracts from Quickstart.
Sign in first: the starter authenticates these requests with the browser's
session cookie. Its create input contains title; completed is set through
the update contract.
The starter already exports apiClient from client/index.ts. Reuse it and
add this file:
// features/todos/client/endpoints.ts
import { apiClient } from "@/client";
import {
createTodo,
deleteTodo,
listTodos,
updateTodo,
} from "@/features/todos/contracts";
export const listTodosEndpoint = apiClient.endpoint(listTodos);
export const createTodoEndpoint = apiClient.endpoint(createTodo);
export const updateTodoEndpoint = apiClient.endpoint(updateTodo);
export const deleteTodoEndpoint = apiClient.endpoint(deleteTodo);Pass the contract builder exported from contracts.ts. Use contract.config
only when integrating with code that cannot accept the builder.
Outside the starter, create a client with createClient from
@beignet/core/client. Browser calls can use relative, same-origin URLs;
server-side HTTP calls need an absolute baseUrl and explicit authentication
headers. For in-process Server Component queries, see
server prefetching.
Making requests
Create, list, and update a todo
Add this complete browser-safe helper. Each request uses the same contracts as the starter's UI:
// features/todos/client/run-todo-example.ts
import {
createTodoEndpoint,
listTodosEndpoint,
updateTodoEndpoint,
} from "./endpoints";
export async function runTodoExample() {
const created = await createTodoEndpoint.call({
body: { title: "Try the typed client" },
});
const updated = await updateTodoEndpoint.call({
path: { id: created.id },
body: { completed: true },
});
const page = await listTodosEndpoint.call();
return { created, updated, page };
}Call runTodoExample() from a signed-in client component's event handler.
Expect a 201 from create and 200 from update and list. updated.completed
is true, and reloading the Todos page shows the saved todo. Direct client
calls do not refresh React Query caches; use mutation invalidation
when this helper's work belongs in a React Query UI.
Query transport
The client uses the query transport declared by the contract. Scalar arrays
become repeated parameters, flat objects use deepObject bracket names, and
numbers, booleans, and dates use ordinary URI values. With
validateInput: true, the client validates the query against its Standard
Schema but still serializes the schema input values; a schema transform does
not silently change the wire format.
Empty arrays and objects are omitted unless their transport opts into
{ empty: "preserve" }. Preservation uses a versioned Beignet extension, so
use it only when Beignet typed clients must distinguish empty from omitted.
Standard OpenAPI clients treat those values as equivalent.
With custom headers
Additional headers belong in the call options. This excerpt assumes the endpoint binding above and an app-owned token for an API that accepts bearer authentication; the starter uses cookies instead:
// In an async request handler (excerpt)
const page = await listTodosEndpoint.call({
headers: { authorization: `Bearer ${token}` },
});With AbortSignal
// In an async request handler (excerpt)
const controller = new AbortController();
const request = listTodosEndpoint.call({ signal: controller.signal });
// Cancel with controller.abort(); an aborted request rejects.
const page = await request;React Query automatically passes its signal through queryOptions().
Error handling
call() returns the response body on success and throws a ContractError on
non-2xx responses and local client failures. The endpoint's isError type
guard is the recommended way to handle thrown errors — it narrows the status
and gives you access to typed helpers.
If a contract declares any responses, successful response statuses are treated as exhaustive. For example, a contract with only 401 and 404 responses declared will reject a 200 response as undeclared; use responses: {} when you want to skip response validation.
ContractError.source tells you whether the failure came from "http" (a
non-2xx server response), "client" (local request preparation or
validation), "network" (a failed fetch), or "contract" (a malformed or
contract-invalid response). Expected schema failures keep their specific
validation codes. An unexpected request-preparation failure uses
CLIENT_ERROR, a fetch rejection uses NETWORK_ERROR, and an unexpected
response-processing failure uses RESPONSE_PROCESSING_ERROR while preserving
the native response and status when available. Use
hasSource() or object-form isContractError() when that distinction matters.
For declared route-owned error responses, error.body is the parsed and validated response body. Framework-owned errors use Beignet's standard { code, message, details?, requestId? } envelope when the response includes x-beignet-error-owner: framework. Native transport responses can also produce text or an empty body. error.details is only the nested details field from that envelope or local validation details.
If a server returns a non-2xx status that does not match the declared route error schema and does not include Beignet's ownership header, the client treats it as a contract failure instead of guessing ownership.
Code-based narrowing such as { code: "TODO_NOT_FOUND" } comes from catalog
errors declared on the contract with .errors(...); see Errors
for defining the catalog. These excerpts use todoId, the UUID of a todo
selected by your application.
// In an async handler with the endpoint bindings above (excerpt)
try {
await updateTodoEndpoint.call({
path: { id: todoId },
body: { completed: true },
});
} catch (err) {
if (updateTodoEndpoint.isError(err, { code: "TODO_NOT_FOUND" })) {
// err.status and err.details are narrowed from the catalog entry
console.log("Not found:", err.message);
console.log("Details:", err.details);
} else if (updateTodoEndpoint.isError(err, { status: 404, source: "http" })) {
console.log("Body:", err.body);
} else if (updateTodoEndpoint.isError(err)) {
if (err.hasSource("client") && err.hasCode("INPUT_VALIDATION_ERROR")) {
console.log("Invalid input:", err.details);
}
}
}Use safeCall() when you want explicit result handling instead of exceptions:
const result = await updateTodoEndpoint.safeCall({
path: { id: todoId },
body: { completed: true },
});
if (result.ok) {
console.log(result.data.title);
} else if (updateTodoEndpoint.isError(result.error, { status: 404, source: "http" })) {
console.log("Not found:", result.error.body);
} else {
console.error(result.error.message);
}React Query integration uses call() because TanStack Query already models
failures through its error channel.
When you do not have the endpoint in scope, ContractError is exported from
@beignet/core/client, so error instanceof ContractError plus the
.hasStatus(), .hasCode(), and .hasSource() methods work anywhere.
To turn a failed call into user-facing copy, use contractErrorMessage with
per-call-site catalog overrides; see
Errors.
Configuration
The following excerpts configure createClient, imported from
@beignet/core/client. In the starter, edit the existing apiClient in
client/index.ts so endpoints and React Query share the same configuration.
Global headers
const client = createClient({
headers: () => ({
"x-api-version": "1.0",
}),
providedHeaders: ["x-api-version"] as const,
});Headers can be a function (sync or async) so you can inject tokens dynamically.
Header keys are normalized to lowercase. Use providedHeaders when required
contract headers are supplied globally; those keys become optional at call sites
while validateInput: true still validates the final merged headers.
Custom fetch
const client = createClient({
fetch: customFetch,
});Input validation
Enable validateInput: true to validate path params, query params, request
bodies, and declared request headers against your contract schemas before
sending the request. This catches malformed requests early without a
round-trip. Input validation is off by default.
Path params and request bodies serialize the parsed values returned by their
schemas. Query parameters are different: the client serializes the original
logical input according to the contract's explicit query transport. This keeps
schema transforms from becoming an accidental HTTP format while the server
still applies them after transport decoding. Values that do not match their
declared transport fail locally with INVALID_QUERY_PARAM.
const client = createClient({
validateInput: true,
});Input validation failures never reach the network, so they throw a client-source ContractError with code INPUT_VALIDATION_ERROR. There is no HTTP response to attach: status, body, and response are all undefined, and details holds the schema issues.
try {
await createTodoEndpoint.call({ body: { title: "" } });
} catch (err) {
if (createTodoEndpoint.isError(err, { code: "INPUT_VALIDATION_ERROR" })) {
console.log("Invalid input:", err.details);
}
}If the body schema accepts undefined such as z.object({ ... }).optional(), you can omit body entirely and the client will send no request body.
Response validation
Response validation is on by default: success bodies are validated against the declared response schema, declared error responses are validated the same way, and undeclared statuses are rejected. Set validateResponses: false to opt out.
const client = createClient({
validateResponses: false,
});With response validation off, success bodies are returned as-is and undeclared statuses are accepted. Non-2xx responses still throw: the client classifies the error structurally, keeping the response status and using the body's code, message, and details when present, falling back to HTTP_ERROR. Code-based narrowing such as isError(err, { code: "TODO_NOT_FOUND" }) keeps working.
You forfeit contract-drift detection — a server response that no longer matches your contract flows through silently instead of failing with RESPONSE_VALIDATION_ERROR or UNDECLARED_RESPONSE_STATUS. A response that fails to parse as JSON still throws INVALID_JSON; that is a transport failure, not validation.
Request bodies are supported for POST, PUT, and PATCH contracts. Passing body or rawBody to GET, HEAD, DELETE, or OPTIONS contracts throws INVALID_REQUEST_BODY.
Raw request bodies
Use body for contract-validated JSON requests. Use rawBody only when the transport body should be sent as-is, such as FormData, Blob, ArrayBuffer, a stream, or pre-serialized text.
const formData = new FormData();
formData.set("avatar", file);
await uploadAvatarEndpoint.call({
rawBody: formData,
});rawBody is not schema-validated or JSON-serialized, and the client does not
add Content-Type: application/json for it. Text responses are parsed as
strings, so a route can declare z.string() for a text/plain response.
For binary downloads or streaming responses, use a transport-owned route that
returns a native Response and call it with platform fetch. A contract with
.responses({ 200: null }) declares that the typed client should see an empty
body; it is the right OpenAPI shape for file and stream routes, not a typed
payload reader for bytes.