Building an API

Router

Implement a handler for each route, with full type inference from the contract.

The handlers are your server's implementation of the contract, one function per route. Inputs arrive validated and typed, and return values are checked against the contract's declared responses. Bind them with server.router, then bind and mount them.

server.router

server.router(router) types the handler tree against the contract, one function per route, grouped to mirror the contract. The server handle comes from new KizunaServer(contract).

Each group lives in its own file, typed against its group in the contract with the Router type so inputs and responses stay inferred:

// users.router.ts
import type { Router } from '@ts-kizuna/express'; // or @ts-kizuna/fastify, @ts-kizuna/hono, @ts-kizuna/next
import type { contract } from './contract';

export const users: Router<typeof contract>['users'] = {
    listUsers: async ({ query }) => {
        const found = await db.users.findMany({
            skip: (query.page - 1) * query.limit,
            take: query.limit,
        });
        return {
            status: 200,
            body: {
                users: found,
                total: await db.users.count(),
            },
        };
    },
    createUser: async ({ body }) => {
        const user = await db.users.create(body);
        return {
            status: 201,
            body: user,
        };
    },
    getUser: async ({ params }) => {
        const user = await db.users.findById(params.id);
        if (!user) {
            return {
                status: 404,
                body: {
                    detail: 'Not found',
                },
            };
        }
        return {
            status: 200,
            body: user,
        };
    },
};

server.router then composes the groups into the full handler tree. Keep this in its own router.ts:

// router.ts
import { server } from './server';
import { users } from './users.router';
import { health } from './health.router';

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

You can also type a single group inline by passing its key as the first argument, server.router('users', { ... }), instead of annotating with Router.

Handler arguments

Each handler receives an object with the validated inputs for that route:

PropertyAvailable when
paramsRoute path has :param placeholders
queryRoute has a query schema
bodyRoute has a body schema
headersRoute has a headers schema
throwErrorAlways
authRoute is secured by the contract's auth map
requestContextContract declares requestContext
pluginsContract declares plugins
jobsContract declares jobs
req, resExpress adapter
cHono adapter
request, replyFastify adapter
requestNext.js adapter

Identity context

When the contract's auth map secures a route, the handler receives the identity's context under auth, keyed by the identity's name. That context is whatever the guard returned. There is nothing to look up or cast, because the guard already ran, so the value is simply there, typed by the identity's context schema. Values declared under requestContext arrive the same way, under requestContext on every route.

listMembers: ({ auth }) => ({
    status: 200,
    body: {
        members: findMembersExcept(auth.user.userId),
    },
}),

A route requiring several identities receives all of them under auth:

transfer: ({ body, auth }) => {
    // auth.user.userId and auth.member.workspaceUserId are both available and typed
},

On routes with an access gate, the constrained fields narrow to what the gate allows. If the auth map says deleteWorkspace requires member with role: 'owner', then inside that handler auth.member.role is typed 'owner'. The runtime check and the type agree by construction:

deleteWorkspace: ({ auth }) => ({
    status: 200,
    body: {
        ok: auth.member.role === 'owner',
    },
}),

Return type

Return { status, body } matching one of the route's declared responses. TypeScript enforces that the status and body match.

// Route: responses: { 200: UserSchema, 404: ProblemDetailsSchema }
return {
    status: 200,
    body: user,
};

return {
    status: 404,
    body: {
        detail: 'User not found',
    },
};

For routes with typed response headers, include headers too:

return {
    status: 200,
    body: user,
    headers: {
        'x-request-id': req.headers['x-request-id'] ?? '',
    },
};

Error responses

Use ProblemDetailsSchema from @ts-kizuna/core/schemas in your routes to define error status codes. This is the same shape produced by deny() in guards and all built-in kizuna errors.

src/contract/contract.ts
import { ProblemDetailsSchema } from '@ts-kizuna/core/schemas';
import { k } from './k';

const usersRoutes = k.routes('users', {
    getUser: {
        method: 'GET',
        path: '/users/:id',
        responses: {
            200: UserSchema,
            404: ProblemDetailsSchema,
        },
    },
});

In the handler, use the throwError helper to return typed error responses. It accepts the same { status, body } as a normal return but throws internally, which is useful in branching logic where you want to bail out early.

getUser: async ({ params, throwError }) => {
    const user = await db.users.findById(params.id);
    if (!user) {
        return throwError({
            status: 404,
            body: {
                detail: 'Not found',
            },
        });
    }
    return {
        status: 200,
        body: user,
    };
},

TypeScript enforces that the status and body match the routes, same as a return value.

Validation errors

When a request body or query fails schema validation, kizuna automatically returns a 400 response before the handler runs:

{
    "type": "about:blank",
    "title": "Bad Request",
    "status": 400,
    "detail": "Request validation failed",
    "errors": [
        {
            "code": "invalid_type",
            "path": ["email"],
            "message": "Expected string, received number"
        },
        {
            "code": "invalid_phone_number",
            "path": ["phone"],
            "message": "Must include country code"
        }
    ]
}

Each issue includes a code that tells you why the field failed, not just that it failed. Codes come from Zod's built-in validation:

CodeWhen it fires
invalid_typeWrong type or missing required field
too_smallBelow min() / minLength()
too_bigAbove max() / maxLength()
invalid_string_formatFailed email(), url(), uuid(), etc.
unrecognized_keysExtra keys when using strict()
not_multiple_ofFailed multipleOf()
custom.refine() or .superRefine() check

User-defined checks (.refine()) report as custom by default. To emit a typed code instead, like invalid_phone_number above, declare it in validation.issueCodes on new Kizuna() and raise it with k.issue. It then shows up in errors[].code as a typed literal on both the server and the generated client, not a bare string.

The message and path fields come directly from your Zod schemas. If you use custom messages (e.g. z.string().min(1, "Name is required")), those are what the client receives.

You don't need to handle this yourself. kizuna validates against the routes and rejects invalid requests automatically.

This 400 response is reflected in the fetch client return type and the OpenAPI spec for any route that declares a body or query schema.

If you declare your own 400 response in the routes, the client will see a union of both your type and kizuna's ValidationError. Use isValidationError on the client to distinguish them.

RouteHandler and Router types

Annotate a single route, one group, or the whole tree:

import type { RouteHandler, Router } from '@ts-kizuna/express';
import { contract } from './contract';

// Single route
const getUser: RouteHandler<typeof contract.routes.users.getUser> = async ({ params }) => {
    const user = await db.users.findById(params.id);
    if (!user) {
        return {
            status: 404,
            body: {
                detail: 'Not found',
            },
        };
    }
    return {
        status: 200,
        body: user,
    };
};

// One group, what each split file exports
const users: Router<typeof contract>['users'] = { ... };

// Whole tree, pass straight to `server.router` or `server.api`
const router: Router<typeof contract> = { ... };

Type Router against the contract, which carries the auth map and request context, so handlers of secured routes get their identity context and every handler gets its request-context values. RouteHandler carries the same, nested groups included (contract.routes.members.session.me).

Reference

Next steps

Mount your router with an adapter:

On this page