Build fully typed REST APIs with TypeScript
Write one contract. Get a fully typed server, an OpenAPI spec, Swift and Kotlin clients, and more.
Battle-tested in production. The syntax may still change before v2, so pin your version.Battle-tested in prod.
Read the FAQGenerate a complete OpenAPI spec from the contract you already wrote.
Native client generationTyped API clients for Swift (iOS/macOS) and Kotlin (Android/JVM).
MCP server generationExpose your API as MCP tools so AI assistants can call your endpoints.
TanStack QueryTyped query and mutation options with caching and invalidation.
AdaptersMount your API on Express, Fastify, Hono, or Next.js.
Typed authenticationIdentities, roles, and per-route access control declared on the contract.
StreamingStream an AI reply as typed events. Yield them from the handler, read them with for await in the client.
ToolsDeclare the tools an AI assistant can call. Run them on the server, and read each call typed in your client.
PluginsExtend your API with features built on the contract you already wrote, fully typed in your handlers.
Request contextDeclare request-scoped values once, and every handler receives them typed, resolved once per request.
Scheduled jobsDeclare cron work next to its handler and tick it from any platform scheduler, or run it in process.
CachingDeclare a cache policy on a response and every adapter sends Cache-Control and Vary.
RPC-like clientCall your API like a function. Every route is a method with typed inputs and a response typed by status code.
Deprecation and sunsetDeprecate routes and fields, and it shows up in your editor, OpenAPI, Swift, Kotlin, and the response headers.
Spec-driven everythingHTTP, OpenAPI, OAuth, and MCP: every status code, error body, and header sits where the spec says it should.
The idea
One contract is the source of truth. Your server, your clients, and every generated artifact read from it.
export const k = new Kizuna();const UserSchema = Kizuna.model({ // shows up as a named User in OpenAPI, Swift, and Kotlin title: 'User', schema: z.object({ id: z.string(), name: z.string(), }),});const users = k.routes({ getUser: { method: 'GET', path: '/users/:id', responses: { 200: UserSchema, 404: ProblemDetailsSchema, // or ProblemDetailsSchema.extend({ ... }) to add extra fields }, },});export const contract = k.contract({ routes: { users, },});server.router({ users: { getUser: async ({ params, throwError }) => { const user = await db.users.findById(params.id); if (!user) throwError({ status: 404, body: { detail: 'Not found', }, }); return { status: 200, body: user, }; }, },});/users/{id}: get: operationId: getUser parameters: - name: id in: path required: true schema: type: string responses: '200': content: application/json: schema: $ref: '#/components/schemas/User' '404': content: application/problem+json: schema: $ref: '#/components/schemas/ProblemDetails'HTTP/1.1 200 OKContent-Type: application/json{ "id": "1", "name": "Ada"}HTTP/1.1 404 Not FoundContent-Type: application/problem+json{ "type": "about:blank", "status": 404, "detail": "Not found"}const apiClient = new KizunaClient(contract, { baseUrl: 'http://localhost:3000',});const res = await apiClient.users.getUser({ params: { id: '1', },});if (res.status === 200) { res.body; // User, fully typed} else { throw new Error(res.body.detail);}const api = new KizunaTanstackQuery(contract, apiClient);const { data } = useQuery( api.users.getUser.queryOptions({ input: { params: { id: '1', }, }, }));// invalidate every users queryqueryClient.invalidateQueries({ queryKey: api.users.key(),});let client = APIClient( baseURL: URL(string: "http://localhost:3000")!)do { let res = try await client.users.getUser( .params( id: "1" ) ) res.body // User, Codable} catch { error // typed failure (e.g. .notFound)}val client = APIClient( baseUrl = "http://localhost:3000")try { val res = client.users.getUser { params( id = "1" ) } res.body // User, @Serializable} catch (error: APIClient.UsersGetUser.Failure.NotFound) { error.body.detail // typed failure}{ "name": "Ada", "email": "nope"}HTTP/1.1 400 Bad RequestContent-Type: application/problem+json{ "status": 400, "errors": [ { "code": "invalid_string_format", "path": ["email"], "message": "Invalid email" } ]}searchUsers: { deprecated: { message: 'use listUsers instead', date: '2026-03-01', }, sunset: '2027-01-01', ...}// Editor strikethrough// OpenAPI deprecated: true// Swift @available(*, deprecated, message: "use listUsers instead")// Kotlin @Deprecated("use listUsers instead")// HTTP Deprecation: @1772323200// HTTP Sunset: Fri, 01 Jan 2027 00:00:00 GMTreply: async ({ body }) => ({ status: 200, body: async function* ({ signal }) { const stream = anthropic.messages.stream( { model: 'claude-opus-5', max_tokens: 64000, messages: [ { role: 'user', content: body.prompt, }, ], }, { signal } ); for await (const text of textDeltas(stream)) { yield { event: 'delta', data: { text } }; } const { usage } = await stream.finalMessage(); yield { event: 'done', data: { outputTokens: usage.output_tokens } }; },}),listEvents: { method: 'GET', path: '/events', responses: { 200: { body: z.array(EventSchema), cache: { scope: 'public', sharedMaxAge: 600, // ten minutes on the CDN staleWhileRevalidate: 60, }, }, 404: { body: ProblemDetailsSchema, cache: { scope: 'public', maxAge: 60, // misses are cached too }, }, },}// Cache-Control: public, s-maxage=600, stale-while-revalidate=60Inside a handler
Whatever the contract declares, the handler gets it validated and typed.
Typed from the path string itself. Rename a param and every handler that reads it fails to compile.
getReport: async ({ params }) => { const report = await db.reports.findFirst({ where: { month: params.month, year: params.Runs anywhere
The same contract and router move between adapters, and the framework underneath stays available to you.
Express
req, res
Fastify
request, reply
Hono
c, c.env
Next.js
request
Every handler gets params, query, body, and headers validated the same way, and each adapter hands you its own primitives on top.
For Cloudflare Workers, Deno, or Bun, use the Hono adapter.
Start here
Ready to build?
8 minutes from an empty file to a typed client calling a real endpoint.