Migration

Migrating from ts-rest

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

ts-kizuna is inspired by ts-rest and the ideas it pioneered. ts-rest proved that routes-first TypeScript APIs could work beautifully, and a lot of people built real things with it, ourselves included. We truly appreciate what the team behind ts-rest built. ts-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 ts-kizuna equivalent.

Package mapping

ts-restts-kizunaNotes
@ts-rest/core@ts-kizuna/core + @ts-kizuna/fetchClient lives in a separate package
@ts-rest/express@ts-kizuna/express
@ts-rest/next@ts-kizuna/next
@ts-rest/open-api@ts-kizuna/openapi

Routes

initContract -> new Kizuna() + k.routes

initContract does not exist in ts-kizuna. Bind your surface with new Kizuna() once, then define groups with k.routes.

// ts-rest
import { initContract } from '@ts-rest/core';
const c = initContract();
export const contract = c.router({ ... });
// ts-kizuna
import { Kizuna } from '@ts-kizuna/core';

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

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

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

Nested routes

// ts-rest
export const contract = c.router({
    users: c.router({ ... }),
});
// ts-kizuna: one group per tag, assembled in the contract
export const users = k.routes('users', { ... });
export const health = k.routes('health', { ... });

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

c.noBody()z.void()

ts-rest uses c.noBody() to mark a route as having no request body or to mark a response as having no body. In ts-kizuna, use z.void() instead. Routes don't require a body field, so for request bodies you can simply remove it.

// ts-rest
import { initContract } from '@ts-rest/core';
const c = initContract();

export const contract = c.router({
    deleteUser: {
        method: 'DELETE',
        path: '/users/:id',
        body: c.noBody(),
        responses: {
            204: c.noBody(),
        },
    },
});
// ts-kizuna: remove `body`, use z.void() for empty responses
import { z } from 'zod';
import { k } from './k';

export const users = k.routes('users', {
    deleteUser: {
        method: 'DELETE',
        path: '/users/:id',
        responses: {
            204: z.void(),
        },
    },
});

For request bodies, just remove the body field. Use z.void() for responses that return no body (like 204).

Contract

ts-rest passes its router straight to the client and server. ts-kizuna adds one step: assemble your route groups into a contract with k.contract, then pass that everywhere (client, createServer).

// ts-kizuna
import { k } from './k';
import { users } from './routes';

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

Client

The ts-rest client lives in @ts-rest/core. In ts-kizuna, the client is a separate package.

// ts-rest
import { initClient } from '@ts-rest/core';
const client = initClient(contract, {
    baseUrl: '...',
});
// ts-kizuna
import { KizunaClient } from '@ts-kizuna/fetch';
const client = new KizunaClient(contract, {
    baseUrl: '...',
});

Server

initServer maps to createServer

initServer does not exist in ts-kizuna. Bind the contract with createServer, use server.router to bind implementations, and server.api to produce a mountable API object.

// ts-rest
import { initServer } from '@ts-rest/express';
const s = initServer();
const router = s.router(contract, { ... });
// ts-kizuna
import { KizunaServer } from '@ts-kizuna/express';

const server = new KizunaServer(contract);

const router = server.router({ ... });

export const api = server.api({
    router,
});

RouterImplRouter, HandlerRouteHandler

// ts-rest
import type { RouterImpl } from '@ts-rest/express';
const router: RouterImpl<typeof contract> = { ... };
// ts-kizuna
import type { Router, RouteHandler } from '@ts-kizuna/express';
import { contract } from './contract';

// Whole handler tree
const router: Router<typeof contract> = { ... };

// Single route
const getUser: RouteHandler<typeof contract.routes.users.getUser> = async ({ params }) => { ... };

Express adapter

createExpressEndpointsapi.mount

// ts-rest
createExpressEndpoints(contract, router, app);
// ts-kizuna
api.mount(app);

In ts-kizuna, api.mount(app) is a method on the api object (produced by server.api) rather than a function taking separate routes and handler arguments.

Next.js adapter

Route file setup

// ts-rest: App Router via @ts-rest/serverless/next
import { createNextHandler } from '@ts-rest/serverless/next';
import { contract } from '@shared/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 };
// ts-kizuna: App Router catch-all
import { api } from '../../server/api';

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

Zod version

ts-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 / ts-kizuna
c.noBody()z.void() or omit body entirely
z.ZodTypeAnyz.ZodType
ZodIssue (from 'zod')z.core.$ZodIssue

Deprecation

ts-rest supports deprecated: true on route definitions and .meta({ deprecated: true }) on Zod schemas. Both are intentionally removed in ts-kizuna.

// ts-rest
deleteUser: {
    method: 'DELETE',
    path: '/users/:id',
    deprecated: true,
    responses: {
        200: z.object({
            success: z.boolean(),
        }),
    },
},
// ts-kizuna: JSDoc only
/**
 * @deprecated
 */
deleteUser: {
    method: 'DELETE',
    path: '/users/:id',
    responses: {
        200: z.object({
            success: z.boolean(),
        }),
    },
},

The /** @deprecated */ JSDoc tag drives IDE strikethrough, the OpenAPI deprecated flag, and the Swift @available(*, deprecated) attribute, all from a single annotation.

Error responses

This is a deliberate departure from ts-rest. ts-kizuna returns RFC 9457 Problem Details (application/problem+json) for every error response. Error responses in a contract 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're 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. ts-kizuna returns 405 with an Allow header per RFC 9110 §15.5.6.

On this page