Building an API

Contract

Define your routes and bundle them into a contract, the source of truth shared by your server and clients.

The contract is the source of truth for your API. Building one is three small steps:

  1. new Kizuna() binds your surface once and exports k.
  2. k.routes describes each endpoint in a group: method, path, and Zod schemas.
  3. k.contract bundles those groups (plus optional settings) into the object you share everywhere.

You hand the result to new KizunaServer() on the server and new KizunaClient() on every client. This page covers defining it. The Router and Mounting pages cover implementing and serving it.

Create the factory

Construct it once, usually in a k.ts file, and export k. Pass a tag set so route groups have a tag to sit under, where the keys become the allowed group names. Then import k wherever you define routes.

src/contract/k.ts
import { Kizuna } from '@ts-kizuna/core';

const tags = Kizuna.tags({
    users: 'Users',
});

export const k = new Kizuna({
    tags,
});

Define your routes

Call k.routes with a tag and your routes. For each one, pick a method and path, then describe what it takes in (body, query, headers) and what it sends back (responses) with Zod schemas.

src/contract/routes/users.ts
import { z } from 'zod';
import { ProblemDetailsSchema } from '@ts-kizuna/core/schemas';
import { k } from './k';

export const UserSchema = z.object({
    id: z.string(),
    name: z.string(),
    email: z.email(),
});

export const usersRoutes = k.routes('users', {
    listUsers: {
        method: 'GET',
        path: '/users',
        query: z.object({
            page: z.number().int().min(1).default(1),
            limit: z.number().int().min(1).max(100).default(10),
        }),
        responses: {
            200: z.object({
                users: z.array(UserSchema),
                total: z.number(),
            }),
        },
    },
    createUser: {
        method: 'POST',
        path: '/users',
        body: z.object({
            name: z.string().min(1),
            email: z.email(),
        }),
        responses: {
            201: UserSchema,
            400: ProblemDetailsSchema,
        },
    },
    getUser: {
        method: 'GET',
        path: '/users/:id',
        responses: {
            200: UserSchema,
            404: ProblemDetailsSchema,
        },
    },
});

Route fields

FieldRequiredDescription
methodYesGET POST PUT PATCH DELETE HEAD OPTIONS
pathYesURL path. Use :name for path parameters
responsesYesResponse schemas keyed by status code
pathParamsNoPath parameter schema, keyed by placeholder name
bodyNoRequest body schema
queryNoQuery string schema
headersNoRequest headers schema
contentTypeNo'application/json' (default), 'multipart/form-data', 'application/x-www-form-urlencoded'
summaryNoShort description shown in OpenAPI
tagsNoOpenAPI tags

Typing path parameters

Path parameters are strings unless you say otherwise. Add pathParams to parse and validate them:

getUser: {
    method: 'GET',
    path: '/users/:id',
    pathParams: z.object({
        id: z.uuid(),
    }),
    responses: {
        200: UserSchema,
    },
},

The keys must be exactly the :name placeholders in the path, so a typo like /users/:usrId is a type error on pathParams.

Each value must be a scalar, since a path segment arrives as one string. Structured values belong in query. To parse one into something richer, use z.string().transform(...):

pathParams: z.object({
    tags: z.string().transform((value) => value.split(',')),
}),

Query, path, and header coercion

Values in the URL and in headers always arrive as strings. ts-kizuna coerces them to the types you declare, so you write the schema you actually mean and get the parsed value on both the client and the server:

DeclareAccepts on the wireYou get
z.number() / z.int()"42"42
z.boolean()"true" / "false"true / false
z.bigint()"9007199254740993"9007199254740993n
z.date()an ISO 8601 stringDate

Arrays of these coerce too (z.array(z.number())). The fetch client serializes Date and bigint values for you, so a z.date() query param round-trips without any manual formatting. Request bodies are sent as JSON and keep their types, so they are never coerced.

Because coercion is built in, z.coerce is not supported, and k.routes throws if any schema uses it. Use the plain schema (z.number(), z.date(), z.bigint()) instead. When you genuinely want to receive a different type than you send, z.string().transform(...) is the explicit, type-safe way to do it.

Response schemas

The simple form is one Zod schema per status code:

responses: {
    200: UserSchema,
    404: ProblemDetailsSchema,
},

Use z.void() for a status that carries no body:

responses: {
    204: z.void(),
    404: ProblemDetailsSchema,
},

To type response headers, give the status a { body, headers } object:

responses: {
    200: {
        body: UserSchema,
        headers: z.object({
            'x-request-id': z.string().optional(),
        }),
    },
},

Bundle into a contract

Assemble your route groups with k.contract and default-export the result. This is the object client and server.api consume.

src/contract/contract.ts
import { k } from './k';
import { usersRoutes } from './routes';

export const contract = k.contract({
    routes: {
        users: usersRoutes,
    },
});

API-wide settings are configured on new Kizuna() and carried onto every contract k.contract produces, so you set them once. That covers tags and validation.issueCodes for custom validation codes.

For non-JSON responses, set contentType to send the body as-is instead of JSON. Responses default to application/json, and errors to application/problem+json. Declare a non-JSON body as z.string():

responses: {
    200: {
        body: z.string(),
        contentType: 'text/csv',
    },
},

For binary responses, use BinarySchema for a body of raw bytes (Uint8Array/Buffer), such as a PDF download:

import { BinarySchema } from '@ts-kizuna/core/schemas';

responses: {
    200: {
        body: BinarySchema,
        contentType: 'application/pdf',
    },
},

That's everything you need to define an API. The rest of this page is optional, so reach for it when you need it.

Auth

Auth is part of the contract too: you declare identities with Kizuna.identity, register them on new Kizuna(), and give k.contract an auth map saying which routes require which identity. That one declaration drives runtime enforcement, typed handler context, the OpenAPI spec's security, and client-side access checks. The Auth guide walks the whole pipeline.

Jobs

Scheduled work is declared on the contract too. k.jobs takes the identity every job requires and the jobs themselves, and the result goes under jobs on k.contract, alongside routes rather than inside it:

export const contract = k.contract({
    routes: {
        users: usersRoutes,
    },
    jobs,
});

Jobs carry their own identity, so they never appear in the auth map, and they stay out of contract.routes, the OpenAPI document, and the generated Swift, Kotlin, and MCP surfaces. The Jobs guide covers declaring, handling, and triggering them.

Going further

Naming schemas with models

By default the generators inline each schema wherever it appears. Use Kizuna.model to give a schema a name they can reuse:

import { Kizuna } from '@ts-kizuna/core';

export const UserSchema = Kizuna.model({
    title: 'User',
    description: 'A user in the system',
    schema: z.object({
        id: z.string(),
        name: z.string(),
        email: z.email(),
    }),
});
  • OpenAPI: extracted into components.schemas.User, and every usage becomes a $ref
  • Swift: emitted as a shared public struct User
  • TypeScript: pair it with an exported type (see Exporting types)

Splitting routes across files

Large contracts split into groups. Declare the tags once with Kizuna.tags, pass them to new Kizuna(), then define each group with k.routes in its own file:

src/contract/tags.ts
import { Kizuna } from '@ts-kizuna/core';

export const tags = Kizuna.tags({
    users: {
        title: 'Users',
        description: 'User management endpoints',
    },
    health: 'Health',
});
src/contract/k.ts
import { Kizuna } from '@ts-kizuna/core';
import { tags } from './tags';

export const k = new Kizuna({
    tags,
});
src/contract/routes/users.ts
import { k } from '../k';

export const usersRoutes = k.routes('users', {
    listUsers: { ... },
    createUser: { ... },
});

An index.ts beside them collects the groups, so contract.ts has one import to make:

src/contract/routes/index.ts
import { usersRoutes } from './users';
import { healthRoutes } from './health';

export const routes = {
    users: usersRoutes,
    health: healthRoutes,
};
src/contract/contract.ts
import { k } from './k';
import { routes } from './routes';

export const contract = k.contract({
    routes,
});

See Project Structure for where the rest of the pieces go.

The group tag's title becomes the OpenAPI tag for every route in the group, and the description is included in the tag definition. A route can also cross-tag by listing tag keys directly:

cancelAccount: {
    method: 'POST',
    path: '/account/cancel',
    tags: ['users', 'health'],
    // ...
},

Deprecating routes and fields

Add a /** @deprecated */ JSDoc tag above a route or a response field, optionally with a message, /** @deprecated use createUser instead */.

/**
 * @deprecated
 */
deleteUser: {
    method: 'DELETE',
    path: '/users/:id',
    responses: {
        200: z.object({
            success: z.boolean(),
        }),
    },
},
getUser: {
    method: 'GET',
    path: '/users/:id',
    responses: {
        200: z.object({
            id: z.string(),
            name: z.string(),
            /**
             * @deprecated
             */
            legacyUsername: z.string().optional(),
        }),
    },
},

IDEs show a strikethrough on deprecated routes and fields right away. To carry the tags into the OpenAPI spec and Swift client, add the one-time build step in Deprecations.

Exporting types

Infer TypeScript types from your schemas in a separate types.ts so consumers can import them without pulling in runtime code:

src/contract/types.ts
import type { z } from 'zod';
import { UserSchema, CreateUserSchema } from './routes';

export type User = z.infer<typeof UserSchema>;
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
import type { User } from '@shared/contract/types';

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

Reference

Next steps

  • Router to implement a handler for each route
  • Mounting to bind the router to the contract and serve it

On this page