Adapters

Express

Mount a ts-kizuna API on an Express 5 application.

@ts-kizuna/express connects a ts-kizuna API to an Express 5 application. It handles routing, request validation, body parsing, and error formatting, all driven by your contract.

Requires Express ≥ 5.

pnpm add @ts-kizuna/express express
bun add @ts-kizuna/express express
npm install @ts-kizuna/express express

Setup

The standard setup:

  • new KizunaServer() binds the contract to a server handle
  • server.router binds typed implementations to the contract
  • server.api combines the router and guards into an API object
  • api.mount(app) mounts the API on an Express app
src/server/server.ts
import { KizunaServer } from '@ts-kizuna/express';
import { contract } from '@shared/contract';

export const server = new KizunaServer(contract);
src/server/router/index.ts
import { server } from './server';

export const router = server.router({
    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,
            };
        },
    },
});
src/server/api.ts
import { server } from './server';
import { router } from './router';

export const api = server.api({
    router,
});
src/index.ts
import express from 'express';
import { api } from './server/api';

const app = express();
app.use(express.json());

api.mount(app);

app.listen(3000);

Handler context

Each handler receives { params, query, body, headers, req, res }. The req and res are the native Express objects, useful when you need to access cookies, set custom headers, or stream a response.

getUser: async ({ params, req, res }) => {
    console.log(req.ip);
    res.setHeader('x-custom', 'value');
    const user = await db.users.findById(params.id);
    return {
        status: 200,
        body: user,
    };
},

Guards

One guard per identity. The credential arrives extracted and typed, alongside the native req/res. See the Auth guide.

src/server/guards.ts
import { server } from './server';

export const requireUser = server.guard('user', async ({ bearer, deny }) => {
    const session = bearer ? await verifySession(bearer.token) : undefined;
    if (!session) {
        return deny(401, 'Unauthorized');
    }
    return {
        userId: session.userId,
    };
});

Middleware

For request-scoped values handlers need, use Kizuna.requestContext. For everything else, such as rate limiting and multipart parsing, use Express's own app.use. Authentication belongs in guards.

Options

api.mount(app, {
    responseValidation: false,
});
OptionDefaultDescription
responseValidationfalseValidate handler return values against response schemas, surfacing as 500 on mismatch. Enable in development.
formatErrornoneReshape error (>= 400) response bytes for migrating clients. Most don't need it (use Problem Details extension members). See Migrating an existing API.

Type-safe request properties

Auth data doesn't need request mutation, because guards return typed context that handlers receive directly. But if non-auth middleware sets custom properties on req, such as a request id, use declaration merging to extend the Express Request interface:

types/express.d.ts
declare global {
    namespace Express {
        interface Request {
            requestId: string;
        }
    }
}

With the augmentation in place, both middleware and handlers are fully typed:

getUser: async ({ params, req }) => {
    console.log(req.requestId);
    // ...
},

Method mismatches

If a path is matched but the method is not defined in the contract, the adapter returns 405 with an Allow header listing the supported methods. This follows RFC 9110 §15.5.6.

Multipart / file uploads

For routes with contentType: 'multipart/form-data', add a multipart middleware such as multer or busboy before the ts-kizuna handler. The adapter reads req.body as-is and does not parse multipart itself.

import multer from 'multer';

const upload = multer({
    storage: multer.memoryStorage(),
});

app.post('/avatar', upload.single('file'), (req, res, next) => {
    next();
});

api.mount(app);

Reference

On this page