@minisylar/express-typed-router
Version:
A strongly-typed Express router with Zod validation and automatic type inference for params, body, query, and middleware
523 lines (393 loc) • 17.4 kB
Markdown
# /express-typed-router
A strongly typed Express router with **Standard Schema** validation, automatic type inference, and ✨ OpenAPI docs.
Define routes once, infer `params` / `body` / `query`, and generate a clean API spec for docs or client generation.
---

## Documentation generated from your codebase
## What you get
- **Typed route handlers** — `req.params`, `req.body`, `req.query` inferred from your route + schema
- **Typed middleware** — middleware can extend `req` and `res.locals`
- **✨ OpenAPI docs** — generated from routes, schemas, and captured responses
- **Schema-agnostic** — any Standard Schema-compatible validator (Zod, Yup, Valibot, Arktype, Joi...)
- **Express 4 & 5** — common patterns supported
- **Client-friendly output** — generate `api.d.ts` and build any client wrapper
---
## Install
```bash
npm install /express-typed-router
pnpm add /express-typed-router
```
Requires Express 4.18+ or Express 5.
---
## Quick start
```ts
import express from "express";
import { z } from "zod";
import { createTypedRouter } from "@minisylar/express-typed-router";
const app = express();
app.use(express.json());
const router = createTypedRouter();
router.get("/users/:id", (req, res) => {
res.json({ id: req.params.id }); // params.id: string
});
router.post(
"/users",
{ bodySchema: z.object({ name: z.string() }) },
(req, res) => {
res.json({ name: req.body.name }); // body.name: string
},
);
app.use("/api", router.getRouter());
app.use("/docs", router.docs({ title: "My API", version: "1.0.0" }));
app.listen(3000);
// http://localhost:3000/docs → interactive API docs
```
---
## Route typing
Path params are inferred from the route string. No extra types needed.
```ts
router.get("/users/:id", (req, res) => {
req.params.id; // string
});
router.get("/flights/:from-:to", (req, res) => {
req.params.from; // string
req.params.to; // string
});
router.get("/posts/:year/:month?", (req, res) => {
req.params.year; // string
req.params.month; // string | undefined
});
```
Schema options infer body and query:
```ts
router.get(
"/search",
{ querySchema: z.object({ q: z.string() }) },
(req, res) => {
req.query.q; // string — validated at runtime, typed at compile time
},
);
router.post(
"/users",
{ bodySchema: z.object({ name: z.string(), email: z.string().email() }) },
(req, res) => {
req.body.name; // string
req.body.email; // string
},
);
```
<details>
<summary><strong>All supported route patterns</strong></summary>
```ts
router.get("/users/:id", handler); // { id: string }
router.get("/flights/:from-:to", handler); // { from: string; to: string }
router.get("/files/:name.:ext", handler); // { name: string; ext: string }
router.get("/posts/:year/:month?", handler); // { year: string; month?: string }
router.get("/files/:path+", handler); // { path: string[] }
router.get("/search/:terms*", handler); // { terms?: string[] }
router.get("/api{/:version}/users", handler); // { version?: string }
router.get("/users/:id(\\d+)", handler); // { id: string }
router.get("/static/*", handler); // { "0": string }
```
</details>
---
## Middleware typing
Declare what a middleware adds to `req`, and that type flows into every handler that uses it.
```ts
import type { TypedMiddleware } from "@minisylar/express-typed-router";
const requireAuth: TypedMiddleware<{ userId: string; email: string }> = (
req,
res,
next,
) => {
const payload = jwt.verify(
req.headers.authorization!,
process.env.JWT_SECRET!,
);
req.userId = payload.userId;
req.email = payload.email;
next();
};
```
**Global middleware** — applied to all routes on the router:
```ts
const router = createTypedRouter()
.useMiddleware(requireAuth)
.useMiddleware(loggingMiddleware);
router.get("/profile", (req, res) => {
req.userId; // string — from requireAuth
req.requestId; // string — from loggingMiddleware
});
```
**Per-route middleware** — scoped to one route, types still merge:
```ts
router.get("/admin", { middleware: [requireAdmin] }, (req, res) => {
req.userId; // from global middleware
req.isAdmin; // from requireAdmin
});
```
> **Note:** `useMiddleware` returns a new router instance. Use method chaining or capture the return value — see [Common Patterns](#common-patterns).
---
## ✨ OpenAPI and docs
Mount the docs endpoint and get a Scalar-based interactive UI plus raw OpenAPI JSON — all generated automatically from your routes.
```ts
app.use(
"/docs",
router.docs({
title: "My API",
version: "1.0.0",
description: "Public API docs",
specOutputPath: "./openapi.json", // write spec to disk (enables watch mode)
}),
);
// GET /docs → Scalar UI
// GET /docs/openapi.json → raw OpenAPI 3.1 spec
```
**What's generated automatically:**
- route paths, methods, and path parameters
- query and body schemas (from `querySchema` / `bodySchema`)
- **response schemas** — inferred from real traffic (see [Response schemas](#response-schemas-from-live-traffic) below)
- tags and summaries — inferred from route paths, or set manually
**Custom route metadata:**
```ts
router.post(
"/users",
{
bodySchema: CreateUserSchema,
responseSchema: UserSchema, // typed responses in the spec
tags: ["Users"],
summary: "Create a user",
description: "Creates a new account and returns the created user.",
},
handler,
);
```
**Multi-router docs** — one `.docs()` call covers everything:
```ts
const api = createTypedRouter()
.use("/users", usersRouter)
.use("/auth", authRouter);
app.use("/docs", api.docs({ title: "My API", version: "1.0.0" }));
// Discovers all sub-routers and merges routes with correct prefixes
```
### Response schemas from live traffic
You don't have to declare what your routes return. The library **observes real responses** (`res.json` / `res.send`), **infers a JSON Schema** from them, and **merges across samples** — so it learns field types, which fields are nullable, and which are optional. This drives both the docs UI and `openapi-typescript` (real response types instead of `unknown`).
By default this runs in **redacted** mode: only the shape is kept, never the values — so no real user data is ever stored or shown.
```ts
// A few responses like { id: 1, email: "a@b.com", nickname: "Al" } and
// { id: 2, email: null } are observed and merged into:
{
type: "object",
properties: {
id: { type: "integer" },
email: { type: ["string", "null"] }, // nullable — seen as null sometimes
nickname: { type: "string" } // optional — missing in some responses
},
required: ["id", "email"] // nickname excluded
}
```
Control it with `sampleResponses`:
| Value | Behavior |
|---|---|
| `true` _(default)_ | **Redacted** — infer schema only. Real values discarded at capture time. Safe to expose. |
| `"live"` | Infer schema **and** attach one real captured response as an example. ⚠️ Examples contain actual data — use only for trusted/internal docs. |
| `false` | Don't observe responses at all. |
```ts
// Safe default — schema only, no real data
app.use("/docs", api.docs({ title: "My API", version: "1.0.0" }));
// Show real example payloads (internal docs only)
app.use("/docs", api.docs({ title: "My API", version: "1.0.0", sampleResponses: "live" }));
// Disable entirely
app.use("/docs", api.docs({ title: "My API", version: "1.0.0", sampleResponses: false }));
```
> Exclude an individual sensitive route from docs with `hidden: true` in its route options — works in any mode.
**How it persists:** schemas fill in as traffic flows. They're held in memory and, when `specOutputPath` is set, written to the file (debounced) as new shapes are observed. On startup the library **reloads** the existing file, so a restart doesn't reset what was already learned — the file is the durable store.
**Gotchas:**
- **Reset accumulated schemas** — inference is merge-only, so a field you _remove_ from a response lingers in the docs. To clear it, delete `openapi.json` and let it rebuild from current traffic.
- **`responseSchema` beats inference** — declare it on routes you want guaranteed-correct (and leak-proof); it overrides whatever traffic suggests.
- **`res.jsonp()` isn't captured** — only `res.json` and `res.send`. JSONP responses won't get an inferred schema (declare `responseSchema` if you need one).
### Schema library support for docs
All validators work for **request validation**. For **OpenAPI schema generation** (showing field names and types in the spec), some libraries need an extra converter package installed in your project. This library auto-detects them at runtime — install the one you need and it just works, no config required.
| Library | Validation | Docs schema | Extra install |
| -------------------------------- | ---------- | ----------- | ------------------------------------------------- |
| Zod 4 | ✅ | ✅ | none — built-in |
| Zod 3 | ✅ | ✅ | `zod-to-json-schema` |
| Valibot | ✅ | ✅ | `/to-json-schema` |
| ArkType | ✅ | ✅ | none — built-in |
| Effect | ✅ | ✅ | none — built-in |
| Yup | ✅ | ⚠️ | not supported — no official JSON Schema converter |
| Joi | ✅ | ⚠️ | not supported — no official JSON Schema converter |
| Decoders / ts.data.json / unhoax | ✅ | ⚠️ | not supported — no schema introspection |
> **⚠️ Partial docs** means routes still appear in the spec with paths, methods, and inferred response schemas — only the request body/query field shapes are missing.
---
## Client types
Set `specOutputPath` in your docs options and the library writes `openapi.json` to disk automatically every time the server starts. That file is a standard OpenAPI 3.1 spec — use it with any OpenAPI-compatible tool: code generators, client SDKs, linters, mocking tools, and more.
For TypeScript projects, [openapi-typescript](https://github.com/openapi-ts/openapi-typescript) is a great option — it generates a `.d.ts` file from the spec that you can use with any HTTP client.
### Setup
**1. Enable spec output:**
```ts
app.use(
"/docs",
router.docs({
title: "My API",
version: "1.0.0",
specOutputPath: "./openapi.json", // written automatically on every server start
}),
);
```
**2. Add the scripts to your `package.json`:**
```json
{
"scripts": {
"dev": "run-p dev:server dev:types",
"dev:server": "node --watch src/server.ts",
"dev:types": "nodemon -L --watch openapi.json --exec \"openapi-typescript ./openapi.json -o ./api.d.ts\""
},
"devDependencies": {
"openapi-typescript": "^7.0.0",
"nodemon": "^3.0.0",
"npm-run-all2": "^7.0.0"
}
}
```
**3. Run it:**
```bash
npm run dev
```
That's it — one command runs everything in parallel:
- `dev:server` — runs your server with `node --watch` (Node 18.11+; no `tsx` needed on Node 23.6+). On every save the server restarts and the library **rewrites `openapi.json` automatically**.
- `dev:types` — `nodemon` watches `openapi.json` and regenerates `api.d.ts` whenever it changes.
Edit a route, save, and your client types update on their own.
> **Why `nodemon -L`?** On Windows, native file watchers miss in-place file writes — the `-L` flag forces polling so the regen reliably fires. On macOS/Linux you can drop it.
> Add `openapi.json` and `api.d.ts` to `.gitignore` — both are generated.
**Prefer to keep it manual?** Skip `nodemon` and `npm-run-all2` entirely — just run the server with `node --watch src/server.ts` and regenerate types on demand with `openapi-typescript ./openapi.json -o ./api.d.ts` whenever you change your API.
> ⚠️ **Avoid a restart loop.** Write the generated `api.d.ts` **outside** the path your server watcher restarts on (or add it to the watcher's ignore list). If your server watches `*.ts` in `src/` and you output the types *into* `src/`, you get: type-gen writes `api.d.ts` → server restarts → spec rewrites → type-gen runs again → ♻️. Putting it in a separate folder (e.g. `shared/`, `generated/`) avoids this.
### Use with `openapi-fetch`
```ts
import createClient from "openapi-fetch";
import type { paths } from "./api";
const client = createClient<paths>({ baseUrl: "http://localhost:3000/api" });
// Path, params, body, and response all typed from the spec
const { data } = await client.GET("/users/{id}", {
params: { path: { id: "123" } },
});
const { data: user } = await client.POST("/users", {
body: { name: "Alice", email: "alice@example.com" },
});
```
### Roll your own client
If you prefer not to add `openapi-fetch`, use the generated types directly with standard `fetch`:
```ts
import type { paths } from "./api";
type Body<
P extends keyof paths,
M extends keyof paths[P],
> = paths[P][M] extends {
requestBody?: { content: { "application/json": infer B } };
}
? B
: never;
type Res<
P extends keyof paths,
M extends keyof paths[P],
> = paths[P][M] extends {
responses: { 200: { content: { "application/json": infer R } } };
}
? R
: unknown;
async function apiFetch<P extends keyof paths, M extends keyof paths[P]>(
path: P,
options: {
method: M;
data?: Body<P, M>;
params?: {
path?: Record<string, string | number>;
query?: Record<string, string | number | boolean>;
};
},
): Promise<Res<P, M>> {
const url = new URL(
String(path).replace(/\{([^}]+)\}/g, (_, key) =>
encodeURIComponent(String(options.params?.path?.[key] ?? "")),
),
"/api",
);
for (const [k, v] of Object.entries(options.params?.query ?? {})) {
url.searchParams.set(k, String(v));
}
const res = await fetch(url, {
method: String(options.method).toUpperCase(),
headers: options.data ? { "Content-Type": "application/json" } : undefined,
body: options.data ? JSON.stringify(options.data) : undefined,
});
return res.json();
}
// Path, method, body, and params all typed from the spec
await apiFetch("/users/{id}", {
method: "get",
params: { path: { id: "123" } },
});
await apiFetch("/users", {
method: "post",
data: { name: "Alice", email: "alice@example.com" },
});
await apiFetch("/search", { method: "get", params: { query: { q: "hello" } } });
```
The same type utilities work with axios — swap `fetch` for `axios.request`.
---
## Common patterns
### Migrate an existing Express app
No rewrite required. Add typed routes alongside existing ones.
```diff
const app = express();
+ const typedRouter = createTypedRouter();
+ typedRouter.get("/users/:id", (req, res) => {
+ res.json({ id: req.params.id }); // typed
+ });
app.get("/health", (_req, res) => res.json({ ok: true })); // untouched
+ app.use("/api", typedRouter.getRouter());
+ app.use("/docs", typedRouter.docs());
```
### Middleware on a group of routes
```ts
// All admin routes share auth middleware and its types
const adminRouter = createTypedRouter()
.useMiddleware(requireAuth)
.get("/users", listUsersHandler)
.delete("/users/:id", deleteUserHandler);
app.use("/admin", adminRouter.getRouter());
```
### Per-feature routers, one doc endpoint
```ts
import {usersRouter} from "./v1/usersRouter"
....
const api = createTypedRouter()
.use("/users", usersRouter)
.use("/orders", ordersRouter)
.use("/auth", authRouter);
app.use("/api/v1", api.getRouter());
app.use("/docs", api.docs({ title: "My API", version: "1.0.0" }));
```
---
## API surface
| | |
| ---------------------------------------- | ------------------------------------------------ |
| `createTypedRouter()` | Create a router |
| `createTypedRouterWithMiddleware(...mw)` | Create a router pre-configured with middleware |
| `createTypedRouterWithConfig(config)` | Create a router with custom error handling |
| `router.useMiddleware(mw)` | Add typed global middleware (returns new router) |
| `router.use(prefix, subRouter)` | Mount a sub-router |
| `router.getRouter()` | Get the underlying Express router |
| `router.docs(options)` | Get the docs + OpenAPI spec router |
| `TypedMiddleware<T>` | Type helper for middleware that extends `req` |
---
## Development
```bash
pnpm install
pnpm build
pnpm type-check
pnpm build:watch
```
---
## License
ISC