Zod

Routes are written in Zod, and the schema carries more than validation.

Supports
Zod 4

A Kizuna route is written in Zod. Validation is the smaller job: the same schemas generate the OpenAPI document, the Swift and Kotlin clients, and the MCP tools, so what you can say in Zod is what your API can say. Every package that works with schemas declares zod@^4.0.0 as a peer dependency.

Standard Schema

Kizuna does not support Standard Schema. We looked into it, and the clients are why we ultimately chose not to.

listEvents: k
    .route({
        method: 'GET',
        path: '/events',
        query: z.object({
            from: z.date(),
        }),
        responses: {
            200: z.array(EventSchema),
        },
    })
    .handler(/* ... */),

One z.date(), and every surface gets the real type:

  • The handler receives from as a Date, coerced from the query string.
  • The Swift client compiles it to Date, the Kotlin client to Instant.
  • The fetch client serializes it back on the way out.

A validator-neutral description cannot say any of that. JSON Schema has no Date, bigint, or transform to describe, a file upload flattens to a string, and a discriminated union loses its discriminator.

Reading one library also pays inside Kizuna: the OpenAPI, Swift, Kotlin, and MCP generators share one toolkit for walking schemas, so a fix or a feature in that reading lands in all of them at once. Routes take Zod, and Kizuna reads all of it.

We wanted every client to behave the same, whatever the language, and one deeply read library is what keeps that true.

Metadata

.meta() is where a schema says things validation cannot. Kizuna reads Zod's own title and description, and kizunajs adds its own:

PropertyTypeWhere it lands
deprecatedboolean | stringdeprecated: true in the OpenAPI document, @available(*, deprecated) in Swift, @Deprecated in Kotlin. A string carries the message into outputs with a place for it.
exampleThe schema's type, or an array of itJSON Schema examples in the OpenAPI document
brandstringA named type in every client and x-kizuna-brand in the OpenAPI document. Set it with Kizuna.brand.
const UserSchema = z.object({
    id: z.string().meta({
        example: 'usr_k7f3q9',
    }),
});

Routes deprecate too, with dates and sunset headers. See Deprecations for every surface.

Named schemas

Kizuna.model registers a schema under a stable name, so the OpenAPI document emits #/components/schemas/User and the Swift and Kotlin clients emit a User type, instead of repeating the shape inline everywhere it appears. Only names registered through Kizuna.model reach the generators.

Branded IDs

A user ID and a workspace ID are both strings, so passing one where the other belongs compiles. Kizuna.brand gives the ID its own type in the handler and in every client, so the swap stops compiling. The wire stays a plain string.

user-id.ts
export const UserId = Kizuna.brand('UserId', z.string());

Use it everywhere the ID appears. In a model:

export const UserSchema = Kizuna.model({
    title: 'User',
    schema: z.object({
        id: UserId,
        name: z.string(),
    }),
});

And in every route that takes one:

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

An ID from a response is already branded, so it passes straight into the next call. You only wrap one yourself when it comes from outside the API, like a URL or a form:

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

const userId = new URLSearchParams(location.search).get('user') ?? '';
const id = toUserId(userId);
let userID = url.lastPathComponent
let id = API.UserId(userID)
val userId = uri.lastPathSegment ?: return
val id = API.UserId(userId)

In the handler, params.id is a UserId and still a string, so it goes into a database call as it is, and a row's plain ID goes into a response body as it is.

Brand the IDs that sit beside other IDs of the same shape, and leave other values plain. A swapped ID is a common mistake for people and coding agents alike, and a brand turns it into a compile error that names both types.

Optional and null fields

A field can be left out, can be null, or both, and Zod says which. On the wire these differ: a request without the key of a .nullable() field gets a 400, and so does a null sent for an .optional() one. Every client follows the schema, so whatever you declare, the generated types only build requests the server accepts.

DeclareKey may be left outValue may be nullTypeScriptSwiftKotlin
.optional()yesnotimezone?: stringString? = nilString? = null
.nullable()noyestimezone: string | nullString?, no defaultString?, no default
.nullish()yesyestimezone?: string | nullString? = nilString? = null

Which to declare

A response field that may be empty is .nullable(). The key is always sent, so a reader checks for null and nothing else, and a handler returns a database row as it is:

avatarUrl: z.string().nullable(),

A request field the caller may skip is .optional(). Leaving it out means "no filter" or "use the default", like page on a list or locale on a new user:

locale: z.string().optional(),

A request field the caller must state, even when it is empty, is .nullable(). On a PUT that replaces a user's preferences, the caller passes timezone: nil on purpose. A screen that forgets the field fails to compile, where a default would have sent null and cleared the user's timezone:

timezone: z.string().nullable(),

A field where leaving it out and sending null mean the same thing is .nullish(). It accepts both, so a caller does whichever is easier:

signature: z.string().nullish(),

Swift and Kotlin have one nil, so the default says which one you get. An .optional() or .nullish() field defaults to nil and is left out of the request. A .nullable() field has no default, so the caller passes nil and the client sends null.

Query, path, header and form fields

A URL, a header and a form field have no null, so every client leaves a nil out, and a .nullable() field there fails as missing. Declare .optional() instead. The ESLint plugin flags it. A .nullish() field there means the same as .optional(), and the clients type it as one.

Coercion

Path parameters, query parameters, and headers arrive as strings, and Kizuna coerces them to the declared schema, so z.number() in a query means the handler receives a number. Declare plain schemas; k.routes throws on z.coerce, since coercion is already built in. See coercion.

Custom issue codes

A .refine() failure reports its code as custom. Declare validation.issueCodes in defineConfig and raise them with k.issue to put a typed, machine-readable code in errors[].code instead.

Binary bodies and uploads

JSON is the default body encoding. For bytes, BinarySchema and FileSchema describe binary response bodies and multipart/form-data upload fields, and the generators map them to native types like Swift's Data.