Clients

Fetch

A fully typed HTTP client for consuming Kizuna routes in the browser, on the server, or in React Native.

@kizunajs/fetch is the typed client for your routes, a wrapper around the native fetch API. It runs anywhere fetch does, React Native included.

You generate it from your routes. The generated file carries named types and a table of methods and paths, and imports nothing else.

pnpm add @kizunajs/fetch@beta
bun add @kizunajs/fetch@beta
npm install @kizunajs/fetch@beta

Add it to your config

Point fetchClient at the file you want written:

kizuna.config.ts
import { fetchClient } from '@kizunajs/fetch/server';

export default defineConfig({
    adapter: expressAdapter(),
    routes: {
        users,
    },
    clients: [
        fetchClient({
            output: './src/lib/api-client.generated.ts',
        }),
    ],
});
kizuna generate

What it writes

src/lib/api-client.generated.ts
/* eslint-disable */
/**
 * Generated by @kizunajs/fetch. Do not edit.
 *
 * Regenerate: kizuna generate
 */
import { createGeneratedClient, type ClientConfig, type GeneratedRoutes } from '@kizunajs/fetch';

export namespace API {
    /**
     * A user in the system
     */
    export type User = {
        id: string;
        name: string;
        nickname?: string;
    };

    export namespace UsersGetUser {
        export type Params = {
            id: string;
        };

        export type Result =
            | { status: 200; body: User; headers: Record<string, string> }
            | { status: 404; body: ProblemDetails; headers: Record<string, string> };
    }
}

Every model keeps the title you gave Kizuna.model, and is declared once however many routes use it.

Create the client

The generated file exports createClient. Wrap it once, where the rest of your app imports it from:

src/lib/api-client.ts
import { createClient } from './api-client.generated';

export const apiClient = createClient({
    baseUrl: 'http://localhost:3000',
});

The client mirrors your route groups, one function per route.

Call a route

users.ts
// GET /users?page=1&limit=10
const { status, body } = await apiClient.users.listUsers({
    query: {
        page: 1,
        limit: 10,
    },
});

// body is { users: User[]; total: number }
users.ts
// 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);
}
users.ts
// GET /users/:id
const { status, body } = await apiClient.users.getUser({
    params: {
        id: 'usr_abc123',
    },
});

params is required when the path contains :param placeholders, and rejected when it does not.

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:

users.ts
// the route 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?: (request: OutgoingRequest) => 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 the OutgoingRequest it is about to send

Setting a header on the headers of an OutgoingRequest sets it on the request that goes out, which is how a credential that lives in storage is read per request rather than fixed at construction. See React Native.

Request context

When your config declares request context headers, createClient takes them too, typed by what the API asked for, and sends them on every request. This is a different thing from the OutgoingRequest above: these are the headers your API declared with k.requestContext, and the generated file exports their type as RequestContext:

src/lib/api-client.ts
import { createClient } from './api-client.generated';

export const apiClient = createClient({
    baseUrl: 'http://localhost:3000',
    requestContext: {
        'x-posthog-session-id': sessionId,
    },
});

An API that declares none takes ClientConfig alone.

Per-request headers

If a route declares a headers schema, the client requires those headers:

users.ts
// 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.

Authentication

When your routes name an identity with identities, send the credential in baseHeaders. The server's guards read it, and answer 401/403 when it's missing or insufficient:

src/lib/api-client.ts
const apiClient = createClient({
    baseUrl: 'https://api.example.com',
    baseHeaders: {
        Authorization: `Bearer ${token}`,
    },
});

Per-request fetch options

Pass any RequestInit option via fetchOptions:

users.ts
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>;
};

ClientArgs<Route> and ClientResponse<Route> are exported for building on top of the client, as @kizunajs/tanstack-query does. Reach for them when writing your own wrapper; calling routes needs neither.

Streams

On a status the route declares with stream, body is an async iterable in place of a value. Read it with for await, and each message narrows on event to its own data:

chat.ts
const result = await apiClient.assistant.reply({
    body: {
        prompt,
    },
});

if (result.status === 200) {
    for await (const message of result.body) {
        if (message.event === 'delta') console.log(message.data.text);
    }
}

The other statuses arrive parsed as they do today. Aborting the signal in fetchOptions closes the connection.

Validation errors

A route with a body or query schema can answer 400 when the request fails validation. It is in the response type already, without you declaring it.

users.ts
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'
    }
}

A route with neither takes no 400 in its 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:

users.ts
import { isValidationError } from '@kizunajs/fetch';

if (result.status === 400) {
    if (isValidationError(result.body)) {
        // kizuna validation error
    } else {
        // your routes' 400 response
    }
}

Refusals

A route whose auth names an identity carries 401 and 403 in its response type, as Problem Details. A public route carries neither. A route declaring its own 403 keeps that body alongside, the way a declared 400 sits beside the validation error.

workspace.ts
const result = await apiClient.workspace.deleteWorkspace();

if (result.status === 403) {
    console.log(result.body.detail);
}

React Native

The client works in Expo and in bare React Native, importing the same generated client as your website.

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, and the Headers it receives are the ones sent:

src/lib/api-client.ts
import * as SecureStore from 'expo-secure-store';
import { createClient } from './api-client.generated';

export const apiClient = createClient({
    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 puts one inside a multipart body. Hand the client Expo's:

src/lib/api-client.ts
import { fetch } from 'expo/fetch';

export const apiClient = createClient({
    baseUrl: process.env.EXPO_PUBLIC_API_URL,
    fetch,
});

Then build the FormData yourself, since the client forwards a FormData body untouched:

upload-avatar.ts
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 route types the field as the File 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.

Named types

The generated file gives every model a name, the thing the Swift and Kotlin clients have always had:

import type { API } from './api-client.generated';

export function UserCard({ user }: { user: API.User }) {
    return <article>{user.name}</article>;
}

users.getUser becomes UsersGetUser in every generated client, so the three read as one API.

Documentation in the client

Each method carries what the route declared, so an editor shows it at the call site: the route's summary and description, @deprecated with its migration message, and an @example of the call with the arguments it cannot be called without.

/**
 * Delete a user
 *
 * @deprecated use `archiveUser` instead
 *
 * @example
 * const result = await client.users.deleteUser({
 *     params: {
 *         id: '1',
 *     },
 * });
 */
deleteUser(args: { params: API.UsersDeleteUser.Params }): Promise<API.UsersDeleteUser.Result>;

Fields carry theirs too. A schema's description becomes the field's doc comment, and anything it declares under example or examples in Zod metadata becomes an @example:

const UserSchema = z.object({
    email: z.email().meta({
        description: 'Email address',
        example: 'alice@example.com',
    }),
});

The same metadata is what the OpenAPI document publishes as its examples, so the two never drift.

What ships

The generated file imports @kizunajs/fetch and nothing else. No schema library, no handlers, no server code. The table it carries is method, path, content type, and which responses stream:

const routes: GeneratedRoutes = {
    users: {
        getUser: {
            method: 'GET',
            path: '/users/:id',
            responses: {
                200: {},
                404: {},
            },
        },
    },
};

That is all the runtime reads. The types above it are erased at build time.

Keeping it current

The file can fall behind your routes, and it fails loudly when it does: add a route and the client has no method for it, which your compiler catches. Regenerate it as part of your build and have CI fail on a dirty tree afterwards.

Reference

On this page