Client
The client gives you a fully typed HTTP client derived from your contracts. No code generation — just TypeScript inference.
Creating a client
import { createNextClient } from "@beignet/next";
import { getTodo, listTodos, createTodo } from "@/features/todos/contracts";
const client = createNextClient();
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.
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.todos); // 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. The isContractError 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", "client", "network", or "contract" response validation. 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(...).
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.hasCode("VALIDATION_ERROR")) {
console.log("Validation:", 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);
}
You can also use instanceof directly and the .hasStatus() / .hasCode() methods:
import { ContractError } from "@beignet/core/client";
try {
await createTodoEndpoint.call({
body: { title: "New todo" },
});
} catch (error) {
if (error instanceof ContractError) {
if (error.hasStatus(422)) {
console.log("Validation:", error.details);
} else {
console.error(error.status, error.body);
}
}
}
Configuration
Global headers
const client = createNextClient({
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 validate: true still validates the final merged headers.
Custom fetch
const client = createNextClient({
fetch: customFetch,
});
Client-side validation
Enable validate: true to validate request parameters 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.
const client = createNextClient({
validate: true,
});
If the body schema accepts undefined such as z.object({ ... }).optional(), you can omit body entirely and the client will send no request body.
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.
Type safety
The client enforces your contract at the type level. TypeScript will catch mistakes before your code runs.
// TypeScript knows exactly what's required
const todo = await getTodoEndpoint.call({
path: { id: "123" }, // error if missing or wrong type
});
// TypeScript knows the response shape
todo.title; // string
todo.completed; // boolean
// TypeScript prevents invalid usage
await getTodoEndpoint.call({
path: { id: 123 }, // type error: id must be string
});