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 typed

GET 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); // string

With 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.