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-rest | ts-kizuna | Notes |
|---|---|---|
@ts-rest/core | @ts-kizuna/core + @ts-kizuna/fetch | Client 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,
});RouterImpl → Router, Handler → RouteHandler
// 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
createExpressEndpoints → api.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-rest | Zod 4 / ts-kizuna |
|---|---|
c.noBody() | z.void() or omit body entirely |
z.ZodTypeAny | z.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.