Fetch
A fully typed HTTP client for consuming ts-kizuna routes in the browser, on the server, or in React Native.
@ts-kizuna/fetch provides new KizunaClient(), a typed wrapper around the native fetch API. It runs anywhere fetch does, which includes the browser, Node, Deno, Bun, edge runtimes, and React Native.
pnpm add @ts-kizuna/fetchbun add @ts-kizuna/fetchnpm install @ts-kizuna/fetchnew KizunaClient()
import { KizunaClient } from '@ts-kizuna/fetch';
import { contract } from '@shared/contract';
export const apiClient = new KizunaClient(contract, {
baseUrl: 'http://localhost:3000',
});The returned client mirrors the contract's route groups. Each route becomes a function that accepts the route's typed arguments and returns a Promise resolving to the typed response.
Calling routes
// GET /users?page=1&limit=10
const { status, body } = await apiClient.users.listUsers({
query: {
page: 1,
limit: 10,
},
});
// body is { users: User[]; total: number }// POST /users
const result = await apiClient.users.createUser({
body: {
name: 'Alice',
email: 'alice@example.com',
},
});
if (result.status === 201) {
console.log(result.body.id);
} else {
// result.status === 400
console.error(result.body.detail);
}// GET /users/:id
const { status, body } = await apiClient.users.getUser({
params: {
id: 'usr_abc123',
},
});The params field is only required when the route path contains :param placeholders. TypeScript enforces this, so routes with no path parameters do not accept params.
When the route declares a pathParams schema, params is typed by that schema's output type instead of the path template, the same typing the server-side handler receives. Refinements like .brand() flow to the caller:
// contract declares: pathParams: z.object({ id: z.string().brand<'UserId'>() })
const { body } = await apiClient.users.getUser({
params: {
id: userId, // must be a UserId, a plain string is a type error
},
});ClientConfig
export interface ClientConfig {
baseUrl: string;
baseHeaders?: Record<string, string>;
credentials?: RequestCredentials;
fetch?: typeof fetch;
onRequest?: (context: RequestContext) => void | Promise<void>;
}| Option | Description |
|---|---|
baseUrl | Base URL prepended to every route path |
baseHeaders | Headers merged into every request |
credentials | Passed as credentials to every fetch call |
fetch | Custom fetch implementation (e.g. for testing or a polyfill) |
onRequest | Callback before each request, receiving { url, method, headers, route } |
Per-request headers
If a route declares a headers schema, the client requires those headers:
// the route declares: headers: z.object({ 'x-request-id': z.string() })
const { body } = await apiClient.users.getUser({
params: { id: 'usr_abc123' },
headers: { 'x-request-id': crypto.randomUUID() },
});Routes without a headers schema accept an optional headers?: Record<string, string> for arbitrary request headers.
Auth
When the contract secures routes with identities, send the credential in baseHeaders. The server's guards read it and return the typed 401/403 when it's missing or insufficient:
const apiClient = new KizunaClient(contract, {
baseUrl: 'https://api.example.com',
baseHeaders: {
Authorization: `Bearer ${token}`,
},
});Per-request fetch options
Pass any RequestInit option via fetchOptions:
const { body } = await apiClient.users.listUsers({
query: { page: 1 },
fetchOptions: {
signal: AbortSignal.timeout(5000),
},
});Response type
Each call returns a discriminated union over the route's defined status codes:
type ListUsersResponse = { status: 200; body: { users: User[]; total: number }; headers: Record<string, string> };Narrow on status to access the correctly typed body:
const result = await apiClient.users.createUser({ body: { name: 'Alice', email: 'alice@example.com' } });
if (result.status === 201) {
// result.body is User
} else {
// result.status === 400, result.body is a Problem Details object
}Validation errors
Routes with a body or query schema can receive a 400 validation error when the request data fails schema validation. This is automatically included in the response type, so you don't need to declare it in your routes.
const result = await apiClient.users.createUser({
body: {
name: '',
email: 'not-an-email',
},
});
if (result.status === 400) {
for (const issue of result.body.errors) {
console.log(issue.code, issue.path, issue.message);
// 'invalid_string_format', ['email'], 'Invalid email'
}
}Routes without body or query cannot trigger validation, so the 400 is not included in their response type.
isValidationError
If your routes also declares a 400 response on the same route, the body becomes a union of your type and ValidationError. Use the isValidationError type guard to distinguish them:
import { isValidationError } from '@ts-kizuna/fetch';
if (result.status === 400) {
if (isValidationError(result.body)) {
// kizuna validation error
} else {
// your routes' 400 response
}
}If a route does not declare a 400, you don't need this, because result.status === 400 narrows the body to ValidationError directly.
React Native
The client works in Expo and in bare React Native with no extra setup. A mobile app imports the same contract as your website, and gets the same routes and the same types out of it.
Reading a token from storage
baseHeaders is fixed when the client is constructed, so a credential that lives in storage goes in onRequest. It runs before every request, it can be async, and the Headers it receives are the ones the request is sent with:
import { KizunaClient } from '@ts-kizuna/fetch';
import * as SecureStore from 'expo-secure-store';
import { contract } from '@shared/contract';
export const apiClient = new KizunaClient(contract, {
baseUrl: process.env.EXPO_PUBLIC_API_URL,
onRequest: async ({ headers }) => {
const token = await SecureStore.getItemAsync('accessToken');
if (token) headers.set('Authorization', `Bearer ${token}`);
},
});Uploading a file
Expo SDK 54 and later ships a File class, and expo/fetch is the implementation that knows how to put one inside a multipart body. The global fetch does not, so hand the client Expo's:
import { fetch } from 'expo/fetch';
export const apiClient = new KizunaClient(contract, {
baseUrl: process.env.EXPO_PUBLIC_API_URL,
fetch,
});Then build the FormData yourself, because the client forwards a FormData body untouched instead of assembling one from your fields:
import { File } from 'expo-file-system';
const formData = new FormData();
formData.append('userId', userId);
formData.append('file', new File(photo.uri));
const { status } = await apiClient.users.uploadAvatar({
body: formData as never,
});The cast is there because the contract types the field as a File, describing what the server receives once the multipart request lands.
Bare React Native has no File it can build from a path. Append a { uri, name, type } descriptor in its place and leave fetch out of the client config, since that form is the one the global fetch understands.