Skip to content

Repository files navigation

@minisylar/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.


Scalar UI showing typed routes with request body schemas, enum values, and live response examples

Documentation generated from your codebase

What you get

  • Typed route handlersreq.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

npm install @minisylar/express-typed-router
pnpm add @minisylar/express-typed-router

Requires Express 4.18+ or Express 5.


Quick start

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.

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 }

Middleware typing

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: useMiddleware returns a new router instance. Use method chaining or capture the return value — see 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.

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 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 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.

// 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.
// 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 @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 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:

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 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:typesnodemon 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

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:

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.

  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

// 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

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

pnpm install
pnpm build
pnpm type-check
pnpm build:watch

License

ISC

About

Typed router for express with auto docs and standard schema support

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages