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 expressbun add @ts-kizuna/express expressnpm install @ts-kizuna/express expressSetup
The standard setup:
new KizunaServer()binds the contract to aserverhandleserver.routerbinds typed implementations to the contractserver.apicombines the router and guards into an API objectapi.mount(app)mounts the API on an Express app
import { KizunaServer } from '@ts-kizuna/express';
import { contract } from '@shared/contract';
export const server = new KizunaServer(contract);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,
};
},
},
});import { server } from './server';
import { router } from './router';
export const api = server.api({
router,
});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.
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,
});| Option | Default | Description |
|---|---|---|
responseValidation | false | Validate handler return values against response schemas, surfacing as 500 on mismatch. Enable in development. |
formatError | none | Reshape 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:
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);