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.

API reference