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-rest | Kizuna | Notes |
|---|---|---|
@ts-rest/core | kizunajs + @kizunajs/fetch | Client 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:
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.
import { initContract } from '@ts-rest/core';
const c = initContract();
export const contract = c.router({ ... });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
export const contract = c.router({
users: c.router({ ... }),
});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.
export const contract = c.router({
deleteUser: {
method: 'DELETE',
path: '/users/:id',
body: c.noBody(),
responses: {
204: c.noBody(),
},
},
});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:
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
import { initClient } from '@ts-rest/core';
const client = initClient(contract, {
baseUrl: '...',
});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:
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.
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),
}),
});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.
import type { RouterImpl } from '@ts-rest/express';
const router: RouterImpl<typeof contract> = { ... };getUser: k
.route({ ... })
.handler(async ({ params }) => { ... }),Express adapter
createExpressEndpoints โ kizuna.api.mount
createExpressEndpoints(contract, router, app);import kizuna from '../kizuna.config';
kizuna.api.mount(app);Next.js adapter
Route file setup
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 };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.
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-rest | Zod 4 / Kizuna |
|---|---|
c.noBody() | z.void() or omit body entirely |
z.ZodTypeAny | z.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.