Config

Assemble your API in kizuna.config.ts, mount it, and say where its generated clients go.

kizuna.config.ts sits at the root of your app, beside package.json. It assembles everything the API is made of, hands back the api you mount, and says where each generated client is written. The tooling looks for it by name.

The file

kizuna.config.ts
import { defineConfig } from 'kizunajs';
import { expressAdapter } from '@kizunajs/express';
import { swiftClient } from '@kizunajs/swift';
import { routes } from './src/routes';
import { user } from './src/identities';

export default defineConfig({
    adapter: expressAdapter(),
    routes,
    auth: {
        identities: {
            user,
        },
    },
    clients: [
        swiftClient({
            output: './ios/MyApp/Generated/APIClient.swift',
            namespace: 'API',
        }),
    ],
});

It default-exports two things: api, which you mount and which every generator reads, and clients, which the CLI writes.

src/index.ts
import kizuna from '../kizuna.config';

kizuna.api.mount(app);

adapter decides what every handler receives beside its validated inputs. Express here puts req and res in scope for all of them, typed.

See defineConfig for every field it takes.

What each key brings

KeyWhat it brings
authThe identities a route's auth can require. Each carries its own guard, so this is all the config needs. An identity used without a guard refuses to assemble. See Authentication.
requestContextValues every handler receives, resolved once per request. Resolvers run on every route, before the guards, and never deny. See Request Context.
jobsScheduled work. api.mount serves the two job endpoints, and every handler receives a typed jobs runner so a route can run one in process.
pluginsRoutes and helpers installed beside your own, as a list. See below, and Create a Plugin to write one.
validationThe code values k.issue may emit beyond Zod's own.
tagsThe OpenAPI tags route groups sit under. See k.tags.

Using what a plugin offers

A plugin can hand your handlers functions to call, under plugins:

src/routes/users.ts
updateUser: k
    .route({
        method: 'PATCH',
        path: '/users/:id',
        body: UpdateUserSchema,
        responses: {
            200: UserSchema,
        },
    })
    .handler(async ({ params, body, plugins }) => {
        const user = await db.users.update(params.id, body);
        plugins.audit.record('users.updateUser');

        return {
            status: 200,
            body: user,
        };
    }),

Destructure it for the short form:

.handler(async ({ params, body, plugins: { audit } }) => {

Mounting

Import the config and mount kizuna.api:

Express:

src/index.ts
import express from 'express';
import kizuna from '../kizuna.config';

const app = express();
app.use(express.json());

kizuna.api.mount(app);

app.listen(3000);

Fastify:

src/index.ts
import Fastify from 'fastify';
import kizuna from '../kizuna.config';

const app = Fastify();

await kizuna.api.mount(app);

app.listen({
    port: 3000,
});

Hono:

src/index.ts
import { Hono } from 'hono';
import kizuna from '../kizuna.config';

const app = new Hono();

kizuna.api.mount(app);

export default app;

Next.js:

src/app/api/[...kizuna]/route.ts
import kizuna from '../../../../kizuna.config';

export const { GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS } = kizuna.api.mount({
    basePath: '/api',
});

Each adapter page covers its own options, error handling, and middleware.

Middleware

Rate limiting, multipart parsing, and the rest go in your framework's own middleware (app.use, Fastify hooks). Authentication belongs in guards, request-scoped values in requestContext.

Clients

Each client comes from the package that generates it, so its options are typed there and a generator you have not installed cannot be configured by accident.

TargetImport fromWrites
fetchClient@kizunajs/fetchA typed TypeScript client
swiftClient@kizunajs/swiftA native Swift client
kotlinClient@kizunajs/kotlinA native Kotlin client
openApiDocument@kizunajs/openapiThe OpenAPI document, to disk
kizuna.config.ts
clients: [
    fetchClient({
        output: './packages/api-client/src/generated.ts',
    }),
    openApiDocument({
        output: './openapi.yaml',
    }),
],

The generated client holds the route shapes and the schemas, and is what a browser imports.

Client options

OptionApplies toDescription
outputallPath the generated file is written to
namespaceSwift, KotlinName of the generated namespace, API by default
packageKotlinPackage the generated file declares
camelCasePropertiesSwift, KotlinConvert wire field names to camelCase, mapping the wire name back
unknownEnumCaseSwift, KotlinGive every enum an unknown case, so an unrecognised value still decodes

openApiDocument takes the same overrides generateOpenApi does, and picks its format from the output extension: .json renders JSON, anything else renders YAML.

Two clients of one language

Add two entries.

kizuna.config.ts
clients: [
    swiftClient({
        output: './ios/MyApp/Generated/APIClient.swift',
        namespace: 'API',
    }),
    swiftClient({
        output: './ios/MyApp/Generated/OpenEnumAPIClient.swift',
        namespace: 'OpenEnumAPI',
        unknownEnumCase: true,
    }),
],

More than one API

One config is one API. A second config is a second API, with its own routes, its own identities and its own generated clients.

When to split

Split when the two have different callers. A public API versioned for outside integrators and an internal admin API answer to different people, change on different schedules, and want different clients, so one OpenAPI document describing both serves neither.

Split when they are deployed apart. A config has one adapter, so an API on Express and an API on Next.js are already two configs.

Split when the surfaces should not mix. Everything in one config shares a namespace: every route reaches every plugin's exports, one guardSchema shapes every refusal, and every route lands in the same generated client. Two configs keep those apart.

Stay with one config when the routes differ only by prefix. A /admin group under the same identities is a route group, not a second API, and splitting it costs you the shared types.

Two configs

Give each one a file, and each its own clients:

my-repo/
├── kizuna.config.ts              # the public API
├── kizuna.admin.config.ts        # the admin API
└── src/
    ├── k.ts                      # typed by the public Config
    ├── admin/
    │   └── k.ts                  # typed by the admin Config
    └── routes/
kizuna.admin.config.ts
import { defineConfig } from 'kizunajs';
import { expressAdapter } from '@kizunajs/express';
import { fetchClient } from '@kizunajs/fetch/server';
import { operator } from './src/admin/identities';
import { admin } from './src/admin/routes';

export default defineConfig({
    adapter: expressAdapter(),
    routes: {
        admin,
    },
    auth: {
        identities: {
            operator,
        },
    },
    typescript: {
        outputFile: './src/admin/kizuna.types.ts',
    },
    clients: [
        fetchClient({
            output: './src/admin/api-client.generated.ts',
        }),
    ],
});

Each config writes its own Config, so each gets its own k:

src/admin/k.ts
import { Kizuna } from 'kizunajs';
import type { Config } from './kizuna.types';

export const k = new Kizuna<Config>();

A route declared against the admin k sees the admin identities and nothing else, so a public identity on an admin route is a type error rather than a runtime surprise.

Running them

kizuna generate reads one config, so point it at each in turn:

package.json
{
    "scripts": {
        "kizuna:generate": "kizuna generate && kizuna generate --config kizuna.admin.config.ts"
    }
}

The native generators take --config the same way, suffixing an export when a file holds more than the default:

Terminal
kizuna-swift generate --config kizuna.admin.config.ts --output ... --namespace-name AdminAPI

Sharing between them

What both APIs need lives in a module they both import: schemas, models, an identity, or a whole route group. Kizuna.model keeps a shared schema's name, so User is one named type in both generated clients rather than two shapes that happen to match.

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

Serving both from one process is ordinary mounting. On Express, mount takes anything with a use, so a router mounted under a path keeps the admin API off the public one:

src/index.ts
import express from 'express';
import publicApi from '../kizuna.config';
import adminApi from '../kizuna.admin.config';

const app = express();
app.use(express.json());

publicApi.api.mount(app);

const adminRouter = express.Router();
adminApi.api.mount(adminRouter);
app.use('/admin', adminRouter);

On Next.js each API is its own catch-all route file, and mount({ basePath }) is what tells it which prefix it answers on.

kizuna generate

pnpm add -D @kizunajs/cli@beta
bun add -d @kizunajs/cli@beta
npm install --save-dev @kizunajs/cli@beta
Terminal
kizuna generate

It writes two things from kizuna.config.ts: the Config in kizuna.types.ts, and every client under clients. A file that already matches is left alone, so watchers and build tools see no change.

OptionDefaultDescription
--checkoffReport what would be rewritten and write nothing. See below
--configkizuna.config.tsThe config to read
--typesthe config's ownWhere the Config is written, overriding typescript.outputFile

Where the types land is usually the config's business rather than the command's:

kizuna.config.ts
export default defineConfig({
    adapter: expressAdapter(),
    routes,
    typescript: {
        outputFile: './src/kizuna.types.ts',
    },
});

Wire it into your scripts

Two scripts cover everywhere it needs to run:

package.json
{
    "scripts": {
        "kizuna:generate": "kizuna generate",
        "kizuna:check": "kizuna generate --check"
    }
}

Run kizuna:generate after changing a route, an identity, or the config. Run kizuna:check in CI, where it fails on anything nobody regenerated.

Whoever changes your routes needs to know about that first script, and a coding agent working in the repository reads the same files your team does. Say it where they look:

AGENTS.md
After changing a route, run `pnpm kizuna:generate`.

kizuna.types.ts

The Config interface is read from your config's syntax, not by running it. That is what lets k be typed by the config that serves your routes without the routes waiting on a config that loads.

kizuna.types.ts
/**
 * This file was automatically generated by kizuna.
 * DO NOT MODIFY IT BY HAND. Instead, modify your source kizuna config,
 * and re-run `kizuna generate` to regenerate this file.
 */
import type { expressAdapter } from '@kizunajs/express';
import type { tags, user } from '@my-repo/api';

export interface Config {
    adapter: ReturnType<typeof expressAdapter>;
    tags: typeof tags;
    auth: {
        identities: {
            user: typeof user;
        };
    };
}

It carries adapter, tags, auth, requestContext, validation, jobs and plugins. Your routes stay out: they are read at runtime, and typing against them would make the config a prerequisite for declaring the routes it serves.

Point new Kizuna<Config>() at it once and every name you write is checked against it:

src/k.ts
import { Kizuna } from 'kizunajs';
import type { Config } from '../kizuna.types';

export const k = new Kizuna<Config>();

kizuna generate --check

Terminal
kizuna generate --check

The same work without writing. It names whatever has fallen behind and exits 1, so a pipeline fails on a generated client nobody regenerated:

  kizuna.types.ts is behind the config
  src/lib/api-client.generated.ts is behind the config

Commit what kizuna generate writes. The check compares the files on disk against the config, so a generated client that is gitignored reads as missing on every clean checkout and the pipeline fails on it.

Run it in CI beside your typecheck. See Breaking changes for the rest of the pipeline.

writeClients and checkClients from @kizunajs/cli are the same two steps as functions, for a script that does more than the command does.

Reference

On this page