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.
- Typed route handlers —
req.params,req.body,req.queryinferred from your route + schema - Typed middleware — middleware can extend
reqandres.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.tsand build any client wrapper
npm install @minisylar/express-typed-router
pnpm add @minisylar/express-typed-routerRequires Express 4.18+ or Express 5.
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 docsPath params are inferred from the route string. No extra types needed.
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:
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
},
);All supported route patterns
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 }Declare what a middleware adds to req, and that type flows into every handler that uses it.
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:
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:
router.get("/admin", { middleware: [requireAdmin] }, (req, res) => {
req.userId; // from global middleware
req.isAdmin; // from requireAdmin
});Note:
useMiddlewarereturns a new router instance. Use method chaining or capture the return value — see Common Patterns.
Mount the docs endpoint and get a Scalar-based interactive UI plus raw OpenAPI JSON — all generated automatically from your routes.
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 specWhat'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 below)
- tags and summaries — inferred from route paths, or set manually
Custom route metadata:
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:
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 prefixesYou 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.
// 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. |
false |
Don't observe responses at all. |
// 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: truein 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.jsonand let it rebuild from current traffic. responseSchemabeats inference — declare it on routes you want guaranteed-correct (and leak-proof); it overrides whatever traffic suggests.res.jsonp()isn't captured — onlyres.jsonandres.send. JSONP responses won't get an inferred schema (declareresponseSchemaif you need one).
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 | ✅ | ✅ | @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.
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 is a great option — it generates a .d.ts file from the spec that you can use with any HTTP client.
1. Enable spec output:
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:
{
"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:
npm run devThat's it — one command runs everything in parallel:
dev:server— runs your server withnode --watch(Node 18.11+; notsxneeded on Node 23.6+). On every save the server restarts and the library rewritesopenapi.jsonautomatically.dev:types—nodemonwatchesopenapi.jsonand regeneratesapi.d.tswhenever 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-Lflag forces polling so the regen reliably fires. On macOS/Linux you can drop it.
Add
openapi.jsonandapi.d.tsto.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 generatedapi.d.tsoutside the path your server watcher restarts on (or add it to the watcher's ignore list). If your server watches*.tsinsrc/and you output the types intosrc/, you get: type-gen writesapi.d.ts→ server restarts → spec rewrites → type-gen runs again → ♻️. Putting it in a separate folder (e.g.shared/,generated/) avoids this.
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" },
});If you prefer not to add openapi-fetch, use the generated types directly with standard fetch:
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.
No rewrite required. Add typed routes alongside existing ones.
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());// 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());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" }));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 |
pnpm install
pnpm build
pnpm type-check
pnpm build:watchISC
