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
import { createClient } from "@beignet/core/client";
import { getTodo, listTodos, createTodo } from "@/features/todos/contracts";
const client = createClient();
export const getTodoEndpoint = client.endpoint(getTodo);
export const listTodosEndpoint = client.endpoint(listTodos);
export const createTodoEndpoint = client.endpoint(createTodo);Pass the contract builder you exported from the feature's contracts.ts. Reaching for
contract.config is only needed when you are integrating with code that cannot
accept Beignet's contract-like builder shape.
createClient() uses Next-friendly defaults for the base URL in browser
modules. The route types come from the contract you pass to
client.endpoint(contract), not from client construction.
Making requests
GET with path parameters
const todo = await getTodoEndpoint.call({
path: { id: "123" },
});
console.log(todo.title); // fully typedGET with query parameters
const result = await listTodosEndpoint.call({
query: {
completed: true,
limit: 10,
offset: 0,
},
});
console.log(result.items); // Todo[]POST with a body
const newTodo = await createTodoEndpoint.call({
body: {
title: "New todo",
completed: false,
},
});
console.log(newTodo.id); // stringWith custom headers
const todo = await getTodoEndpoint.call({
path: { id: "123" },
headers: {
authorization: `Bearer ${token}`,
},
});With AbortSignal
const controller = new AbortController();
const todo = await getTodoEndpoint.call({
path: { id: "123" },
signal: controller.signal,
});
// Cancel with controller.abort()React Query automatically passes its signal through queryOptions(), so cancellation works out of the box.
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 validation), "network"
(a failed fetch), or "contract" (a malformed or contract-invalid response).
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.
const getTodoEndpoint = apiClient.endpoint(getTodo);
try {
await getTodoEndpoint.call({ path: { id: "123" } });
} catch (err) {
if (getTodoEndpoint.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 (getTodoEndpoint.isError(err, { status: 404, source: "http" })) {
console.log("Body:", err.body);
} else if (getTodoEndpoint.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 getTodoEndpoint.safeCall({
path: { id: "123" },
});
if (result.ok) {
console.log(result.data.title);
} else if (getTodoEndpoint.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
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, and the client serializes the parsed values returned by your schema. Input validation is off by default.
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.