Clients

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/fetch
bun add @ts-kizuna/fetch
npm install @ts-kizuna/fetch

new KizunaClient()

src/lib/api-client.ts
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>;
}
OptionDescription
baseUrlBase URL prepended to every route path
baseHeadersHeaders merged into every request
credentialsPassed as credentials to every fetch call
fetchCustom fetch implementation (e.g. for testing or a polyfill)
onRequestCallback 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:

src/lib/api-client.ts
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:

src/lib/api-client.ts
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.

Reference

On this page