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:
new Kizuna()binds your surface once and exportsk.k.routesdescribes each endpoint in a group: method, path, and Zod schemas.k.contractbundles 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.
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.
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
| Field | Required | Description |
|---|---|---|
method | Yes | GET POST PUT PATCH DELETE HEAD OPTIONS |
path | Yes | URL path. Use :name for path parameters |
responses | Yes | Response schemas keyed by status code |
pathParams | No | Path parameter schema, keyed by placeholder name |
body | No | Request body schema |
query | No | Query string schema |
headers | No | Request headers schema |
contentType | No | 'application/json' (default), 'multipart/form-data', 'application/x-www-form-urlencoded' |
summary | No | Short description shown in OpenAPI |
tags | No | OpenAPI 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:
| Declare | Accepts on the wire | You get |
|---|---|---|
z.number() / z.int() | "42" | 42 |
z.boolean() | "true" / "false" | true / false |
z.bigint() | "9007199254740993" | 9007199254740993n |
z.date() | an ISO 8601 string | Date |
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.
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:
import { Kizuna } from '@ts-kizuna/core';
export const tags = Kizuna.tags({
users: {
title: 'Users',
description: 'User management endpoints',
},
health: 'Health',
});import { Kizuna } from '@ts-kizuna/core';
import { tags } from './tags';
export const k = new Kizuna({
tags,
});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:
import { usersRoutes } from './users';
import { healthRoutes } from './health';
export const routes = {
users: usersRoutes,
health: healthRoutes,
};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:
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>;
}