Migration

Migrating from ts-rest

Side-by-side comparison of ts-rest and Kizuna, for anyone weighing a ts-rest alternative, with notes on deliberate differences.

Kizuna is inspired by ts-rest and the ideas it pioneered. ts-rest showed that routes-first TypeScript APIs work, and a lot of people built real things with it, ourselves included. Kizuna borrows several of its core concepts and keeps the public API deliberately familiar, but takes a different approach under the hood. This page maps every changed API to its Kizuna equivalent.

Package mapping

ts-restKizunaNotes
@ts-rest/corekizunajs + @kizunajs/fetchClient lives in a separate package
@ts-rest/express@kizunajs/express
@ts-rest/next@kizunajs/next
@ts-rest/open-api@kizunajs/openapi

The biggest difference

In ts-rest a route is a shape, and its implementation lives in a parallel tree you keep in step by hand. In Kizuna a route carries the handler that answers it:

Kizuna
export const users = k.routes('users', {
    getUser: k
        .route({
            method: 'GET',
            path: '/users/:id',
            responses: {
                200: UserSchema,
            },
        })
        .handler(async ({ params }) => ({
            status: 200,
            body: await db.users.findById(params.id),
        })),
});

Most of the mapping below follows from that: there is no router to build, and nothing to keep aligned.

Routes

initContract โ†’ new Kizuna() + k.routes

There is no initContract. Create your surface with new Kizuna() once, then declare groups with k.routes.

ts-rest
import { initContract } from '@ts-rest/core';

const c = initContract();

export const contract = c.router({ ... });
Kizuna
import { Kizuna } from 'kizunajs';
import type { Config } from '../kizuna.types';

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

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

Config is written by kizuna generate from your kizuna.config.ts. It is what types every handler and checks every name you write. See new Kizuna().

Nested routes

ts-rest
export const contract = c.router({
    users: c.router({ ... }),
});
Kizuna
export const users = k.routes('users', { ... });
export const health = k.routes('health', { ... });

The tag passed to k.routes (a key of your k.tags set) sets the OpenAPI tag for every route in that group.

c.noBody() โ†’ z.void()

ts-rest uses c.noBody() to mark a route as having no request body, or a response as having no body. In Kizuna, use z.void() for the response and omit body entirely.

ts-rest
export const contract = c.router({
    deleteUser: {
        method: 'DELETE',
        path: '/users/:id',
        body: c.noBody(),
        responses: {
            204: c.noBody(),
        },
    },
});
Kizuna
export const users = k.routes('users', {
    deleteUser: k
        .route({
            method: 'DELETE',
            path: '/users/:id',
            responses: {
                204: z.void(),
            },
        })
        .handler(async ({ params }) => {
            await db.users.delete(params.id);
            return {
                status: 204,
            };
        }),
});

Assembling it

ts-rest passes its router straight to the client and the server. Kizuna assembles everything in one kizuna.config.ts at the root of your app, which is also where the CLI looks:

kizuna.config.ts
import { defineConfig } from 'kizunajs';
import { expressAdapter } from '@kizunajs/express';
import { users } from './src/routes/users';

export default defineConfig({
    adapter: expressAdapter(),
    routes: {
        users,
    },
});

adapter is the one field with no ts-rest counterpart. Naming a framework there is what types every handler's req, c or request, so you never import your framework's types into a route file.

See defineConfig.

Client

ts-rest
import { initClient } from '@ts-rest/core';

const client = initClient(contract, {
    baseUrl: '...',
});
Kizuna
import { createClient } from './api-client.generated';

const client = createClient({
    baseUrl: '...',
});

Where ts-rest hands the contract to the client at runtime, Kizuna writes the client out ahead of time. Your config holds the handlers, so it never reaches a browser; name the file you want under clients and run kizuna generate:

kizuna.config.ts
clients: [
    fetchClient({
        output: './src/lib/api-client.generated.ts',
    }),
],

See Clients.

Server

initServer โ†’ the route's own handler

There is no initServer and no router. The implementation sits on the route.

ts-rest
import { initServer } from '@ts-rest/express';

const s = initServer();
const router = s.router(contract, {
    getUser: async ({ params }) => ({
        status: 200,
        body: await db.users.findById(params.id),
    }),
});
Kizuna
export const users = k.routes('users', {
    getUser: k
        .route({
            method: 'GET',
            path: '/users/:id',
            responses: {
                200: UserSchema,
            },
        })
        .handler(async ({ params }) => ({
            status: 200,
            body: await db.users.findById(params.id),
        })),
});

RouterImpl has no counterpart

ts-rest types a handler tree because the tree is where handlers live. In Kizuna the handler sits on the route, typed from the declaration above it, so there is nothing to annotate and no second tree to keep in step.

ts-rest
import type { RouterImpl } from '@ts-rest/express';

const router: RouterImpl<typeof contract> = { ... };
Kizuna
getUser: k
    .route({ ... })
    .handler(async ({ params }) => { ... }),

Express adapter

createExpressEndpoints โ†’ kizuna.api.mount

ts-rest
createExpressEndpoints(contract, router, app);
Kizuna
import kizuna from '../kizuna.config';

kizuna.api.mount(app);

Next.js adapter

Route file setup

ts-rest
import { createNextHandler } from '@ts-rest/serverless/next';
import { contract } from '@/contract';
import { router } from './router';

const handler = createNextHandler(contract, router, {
    basePath: '/api',
});

export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE, handler as OPTIONS };
Kizuna
import kizuna from '../../../../kizuna.config';

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

Authentication

ts-rest leaves authentication to your framework's middleware. Kizuna declares it: an identity says how the credential travels and what a passing guard provides, the guard sits on that identity, and a route's auth names the one it requires.

src/identities.ts
export const user = k.identity
    .bearer({
        context: z.object({
            userId: z.string(),
        }),
    })
    .guard(async ({ bearer, deny }) => {
        const session = bearer ? await verifySession(bearer.token) : undefined;
        if (!session) {
            return deny({
                status: 401,
                body: {
                    detail: 'Unauthorized',
                },
            });
        }
        return {
            userId: session.userId,
        };
    });

Handlers of a route with auth: 'user' then read auth.user.userId, typed, and the identity is emitted into your OpenAPI document as a security scheme. See Authentication.

Zod version

Kizuna targets Zod 4 only. ts-rest supports Zod 3 and 4.

If you are upgrading from ts-rest and Zod 3, migrate to Zod 4 alongside this change:

Zod 3 / ts-restZod 4 / Kizuna
c.noBody()z.void() or omit body entirely
z.ZodTypeAnyz.ZodType
ZodIssue (from 'zod')z.core.$ZodIssue

Error responses

Kizuna returns RFC 9457 Problem Details (application/problem+json) for every error response. Error responses must use ProblemDetailsSchema (or ProblemDetailsSchema.extend({...}) for extra fields), not an arbitrary shape. Validation errors are Problem Details with a detail string and an errors array, instead of ts-rest's { message, issues }. If you are porting an API whose clients expect the old shape, see Migrating an existing API for how to keep old clients working during the transition.

Method mismatch behaviour

ts-rest returns 404 for requests to a known path with an unsupported method. Kizuna returns 405 with an Allow header per RFC 9110 ยง15.5.6.

On this page