@contract-kit/client
v1.0.0
Published
HTTP client for contract-kit
Downloads
933
Maintainers
Readme
@contract-kit/client
HTTP client for Contract Kit
This package provides type-safe client adapters for making contract-based HTTP requests.
Installation
npm install @contract-kit/client @contract-kit/coreTypeScript requirements
This package requires TypeScript 5.0 or higher for proper type inference.
HTTP client
Creating a client
import { createClient } from "@contract-kit/client";
export const apiClient = createClient({
baseUrl: "https://api.example.com",
headers: async () => ({
"x-app-version": "1.0",
authorization: `Bearer ${getToken()}`,
}),
providedHeaders: ["authorization"] as const,
});Client-side validation
Enable validate: true to validate request parameters and declared request headers against your contract schemas before sending the request. When validation is enabled, the client serializes the parsed path, query, body, and header values returned by your schema.
const client = createClient({
baseUrl: "https://api.example.com",
validate: true,
});If a contract declares request headers with .headers(...), pass them through
the same headers call arg. Header keys are normalized to lowercase before
validation and fetch:
await apiClient.endpoint(getSecureTodo).call({
path: { id: "123" },
headers: {
authorization: `Bearer ${token}`,
},
});Use providedHeaders when required contract headers are supplied by
client-level headers, such as auth/session headers. This makes those keys
optional at each call site while validate: true still validates the final
merged headers.
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 and text responses
Use body for contract-validated JSON. Use rawBody when you intentionally
need to send a non-JSON transport body such as FormData, Blob,
ArrayBuffer, a stream, or pre-serialized text.
await apiClient.endpoint(uploadAvatar).call({
rawBody: formData,
headers: {
// Let the browser set multipart boundaries when using FormData.
},
});rawBody is sent as-is and is not schema-validated or JSON-serialized. The
client only adds Content-Type: application/json for regular JSON body
requests.
Responses with application/json are parsed as JSON. Text responses are parsed
as strings and can be validated with a string response schema.
Making requests
import { apiClient } from "@/lib/api-client";
import { getTodo, createTodo, listTodos } from "@/contracts/todos";
// GET request with path params
const todo = await apiClient
.endpoint(getTodo)
.call({ path: { id: "123" } });
// POST request with body
const newTodo = await apiClient
.endpoint(createTodo)
.call({ body: { title: "Learn Contract Kit" } });
// GET request with query params
const todos = await apiClient
.endpoint(listTodos)
.call({ query: { completed: false, limit: 10 } });Error handling
call() returns the response body for 2xx responses and throws a ContractError for non-2xx responses, local validation failures, malformed responses, and network failures.
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 where the failure came from:
"http": the server returned a non-2xx response"client": local request construction or validation failed"network":fetchfailed before a response was received"contract": a server response was malformed or did not match the contract
For declared route-owned error responses, error.body is the parsed and validated contract response. Framework-owned errors use Contract Kit's standard { code, message, details?, requestId? } envelope when the response includes x-contract-kit-error-owner: framework. Native transport responses may produce text or no body.
If a server returns a non-2xx status that does not match the declared route error schema and does not include the framework 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 {
const todo = await getTodoEndpoint.call({ path: { id: "123" } });
} catch (error) {
if (getTodoEndpoint.isError(error, { code: "TODO_NOT_FOUND" })) {
console.log(error.status); // 404
console.log(error.details); // Typed from the catalog details schema
} else if (getTodoEndpoint.isError(error, { status: 404, source: "http" })) {
console.log(error.status); // 404
console.log(error.body); // Parsed error response body
} else if (getTodoEndpoint.isError(error, { source: "network" })) {
console.log("Network failure:", error.message);
}
}Use safeCall() when you want explicit result handling instead of exceptions:
const getTodoEndpoint = apiClient.endpoint(getTodo);
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);
}Creating API wrappers
// features/todos/api.ts
import { apiClient } from "@/lib/api-client";
import { getTodo, createTodo, deleteTodo } from "@/contracts/todos";
export const todosApi = {
get: (id: string) =>
apiClient.endpoint(getTodo).call({ path: { id } }),
create: (data: { title: string; completed?: boolean }) =>
apiClient.endpoint(createTodo).call({ body: data }),
delete: (id: string) =>
apiClient.endpoint(deleteTodo).call({ path: { id } }),
};API reference
HTTP client
createClient(config)
Creates an HTTP client instance.
const client = createClient({
baseUrl: string;
headers?: () => Promise<Record<string, string>> | Record<string, string>;
providedHeaders?: readonly string[];
fetch?: typeof fetch;
});client.endpoint(contract)
Creates a typed endpoint for a contract.
const endpoint = client.endpoint(getTodo);
const result = await endpoint.call({ path: { id: "123" } });
const safeResult = await endpoint.safeCall({ path: { id: "123" } });Type exports
import { ContractError } from "@contract-kit/client";
import type {
CallArgs,
Client,
ClientConfig,
Endpoint,
EndpointCallArgs,
EndpointErrorResult,
EndpointResult,
EndpointSuccessResult,
InferBody,
InferEndpointErrorResponse,
InferErrorResponse,
InferPathParams,
InferQuery,
InferSuccessResponse,
} from "@contract-kit/client";Related packages
@contract-kit/core- Core contract definitions@contract-kit/react-query- TanStack Query integration@contract-kit/server- Server runtime for contract-backed HTTP routes
License
MIT
