@minisylar/express-typed-router
v1.6.2
Published
A strongly-typed Express router with Zod validation and automatic type inference for params, body, query, and middleware
Downloads
97
Maintainers
Readme
@minisylar/express-typed-router
A strongly-typed Express router with schema validation and automatic type inference for params, body, query, and middleware.
Features
- 🚀 Full TypeScript support with automatic type inference for route parameters
- 🛡️ Schema validation for request body, query parameters, and route params (Zod, Yup, Valibot, Arktype,Joi,Effect,decoders, ts.data.json, unhoax, etc.)
- 🔗 Express.js compatibility - works with Express 4 and Express 5
- 🤝 Mix with existing Express routes - seamlessly integrates with your current codebase
- 📝 JSDoc documentation with comprehensive examples
- 📦 ES Modules and CommonJS support
- 🎯 Zero runtime overhead for type checking
Installation
npm install @minisylar/express-typed-router
# or
pnpm add @minisylar/express-typed-router
# or
yarn add @minisylar/express-typed-routerNote: This package requires Express 4.18.0+ or Express 5.0.0+. For schema validation the library works with multiple popular schema libraries (examples below).
Schema Compatibility
This library is schema-agnostic: it provides a validation plumbing that works with multiple popular schema libraries. Below are short examples showing how you can use different schema libraries with the router. The router expects a schema-like object that can validate input; most adapters are straightforward.
Example with Zod (v3 or v4):
import { z } from "zod"; // or "zod/v4" or "zod/v3" as needed
const userSchema = z.object({ name: z.string() });
router.post("/users", { bodySchema: userSchema }, handler);Example with Yup:
import * as yup from "yup";
const userSchema = yup.object({ name: yup.string().required() });
// pass the yup schema directly as bodySchema; the router will run validation
router.post("/users", { bodySchema: userSchema }, handler);Example with Valibot (valibot):
import { object, string } from "valibot";
const userSchema = object({ name: string() });
router.post("/users", { bodySchema: userSchema }, handler);Example with Arktype:
import { object, string } from "arktype";
const userSchema = object({ name: string() });
router.post("/users", { bodySchema: userSchema }, handler);If a schema library needs an adapter (for example to map its errors to the router's error format), add a small wrapper that runs validation and throws the expected error shape. See the project's examples for concrete adapter patterns.
Note about Joi: Joi's TypeScript typings do not reliably infer the output type from the runtime schema shape. When using Joi you should either:
- provide an explicit generic type for the schema (e.g.
Joi.object<MyType>(...)), - add a variable type annotation (e.g.
const s: Joi.ObjectSchema<MyType> = Joi.object(...)), or - write a small adapter that validates at runtime and exposes a typed result to TypeScript.
Quick Start
import express from "express";
import { z } from "zod";
import { createTypedRouter } from "@minisylar/express-typed-router";
const app = express();
app.use(express.json());
// Create a typed router
const router = createTypedRouter();
// Define routes - parameters are automatically typed!
router.get("/users/:userId", (req, res) => {
// req.params.userId is automatically inferred as string
res.json({ userId: req.params.userId });
});
// Add validation with Zod schemas
router.post(
"/users",
{
bodySchema: z.object({
name: z.string(),
email: z.string().email(),
}),
},
(req, res) => {
// req.body is validated and typed automatically
const { name, email } = req.body;
res.json({ id: "123", name, email });
}
);
const expressRouter = router.getRouter();
app.use("/api", expressRouter);
app.listen(3000);That's it! Your routes now have full type safety and validation.
Works with Existing Express Routes
The typed router seamlessly integrates with your existing Express application - no need to rewrite everything!
import express from "express";
import { createTypedRouter } from "@minisylar/express-typed-router";
const app = express();
app.use(express.json());
// Your existing Express routes continue to work
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
// Existing Express router
const legacyRouter = express.Router();
legacyRouter.get("/legacy/:id", (req, res) => {
res.json({ id: req.params.id });
});
// New typed router with full type safety
const typedRouter = createTypedRouter();
typedRouter.get("/users/:userId", (req, res) => {
// req.params.userId is automatically typed as string
res.json({ userId: req.params.userId });
});
// Extract the Express router before using it
const typedExpressRouter = typedRouter.getRouter();
// Mix them all together
app.use("/api/legacy", legacyRouter);
app.use("/api/v2", typedExpressRouter);
// Gradually migrate your routes to get type safety where you need it!
app.listen(3000);The Main API: createTypedRouter()
createTypedRouter() is the primary and most flexible way to create typed routers. It supports:
- ✅ Global middleware with automatic type merging
- ✅ Per-route middleware with type inference
- ✅ Zod validation for params, body, and query
- ✅ Express 4 & 5 compatibility with full route pattern support
- ✅ Chainable API for easy configuration
Global Middleware
Important: Unlike Express, middleware must be applied using method chaining or capturing returned routers. See the FAQ section for details.
// Method chaining pattern (recommended)
const router = createTypedRouter()
.useMiddleware(authMiddleware)
.useMiddleware(loggingMiddleware)
.useMiddleware(timestampMiddleware);
// All routes automatically get types from all middleware
router.get("/protected", (req, res) => {
// req.userId, req.requestId, req.timestamp all available and typed
});
// Alternative: capturing returned router
const baseRouter = createTypedRouter();
const routerWithMiddleware = baseRouter
.useMiddleware(authMiddleware)
.useMiddleware(loggingMiddleware);
// Use the router with middleware applied
routerWithMiddleware.get("/users", handler);Per-Route Middleware
router.get(
"/admin/:userId",
{
middleware: [adminMiddleware, auditMiddleware],
},
(req, res) => {
// Types from both global AND per-route middleware are merged
// req.userId (global), req.isAdmin (adminMiddleware), req.auditId (auditMiddleware)
}
);Express 4 & 5 Route Pattern Support
Works with all Express routing patterns:
// Named parameters
router.get("/users/:userId", handler); // { userId: string }
// Multiple parameters
router.get("/users/:userId/posts/:postId", handler); // { userId: string; postId: string }
// Consecutive parameters with separators
router.get("/flights/:from-:to", handler); // { from: string; to: string }
router.get("/files/:name.:ext", handler); // { name: string; ext: string }
// Optional parameters (Express 4)
router.get("/posts/:year/:month?", handler); // { year: string; month?: string }
// Repeating parameters (Express 5)
router.get("/files/:path+", handler); // { path: string[] }
// Optional repeating (Express 5)
router.get("/search/:terms*", handler); // { terms?: string[] }
// Optional segments (Express 5)
router.get("/api{/:version}/users", handler); // { version?: string }
// Regex constraints
router.get("/users/:id(\\d+)", handler); // { id: string }
// Wildcards
router.get("/static/*", handler); // { "0": string }Comprehensive Example
import express from "express";
import { z } from "zod";
import { createTypedRouter } from "@minisylar/express-typed-router";
const app = express();
app.use(express.json());
// Create router and define middleware inline (automatically typed!)
const router = createTypedRouter()
.useMiddleware((req, res, next) => {
const token = req.headers.authorization;
req.userId = "user123";
req.isAdmin = token?.includes("admin") || false;
next();
})
.useMiddleware((req, res, next) => {
req.requestId = Math.random().toString(36);
console.log(`[${req.requestId}] ${req.method} ${req.path}`);
next();
});
// Define schemas
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(["user", "admin"]).optional(),
});
const UserQuerySchema = z.object({
include: z.array(z.string()).optional(),
limit: z.coerce.number().int().positive().max(100).default(10),
});
// Routes with full type safety
router.get(
"/users/:userId",
{
querySchema: UserQuerySchema,
},
(req, res) => {
// All properties are automatically typed:
const { userId } = req.params; // string (auto-inferred from route)
const { include, limit } = req.query; // from schema validation
const { userId: authUserId, isAdmin, requestId } = req; // from middleware
res.json({
id: userId,
authUserId,
isAdmin,
requestId,
include,
limit,
});
}
);
router.post(
"/users",
{
bodySchema: CreateUserSchema,
},
(req, res) => {
const { name, email, role } = req.body; // Fully typed from schema
const { userId, isAdmin, requestId } = req; // From middleware
if (role === "admin" && !isAdmin) {
return res.status(403).json({ error: "Insufficient permissions" });
}
res.status(201).json({
id: "new-user-id",
name,
email,
role: role || "user",
createdBy: userId,
requestId,
});
}
);
// Per-route middleware can also be inline
router.delete(
"/users/:userId",
{
middleware: [
(req, res, next) => {
if (!req.isAdmin) {
return res.status(403).json({ error: "Admin required" });
}
req.hasAdminAccess = true;
next();
},
], // No need for 'as const' - middleware arrays are automatically typed
},
(req, res) => {
// Types from BOTH global middleware AND per-route middleware
const { userId } = req.params; // From route
const { userId: authUserId, requestId } = req; // From global middleware
const { hasAdminAccess } = req; // From per-route middleware
res.json({
deleted: userId,
deletedBy: authUserId,
requestId,
hasAdminAccess,
});
}
);
app.use("/api", router.getRouter());
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});With Explicit TypeScript Types
For TypeScript users who prefer explicit typing, you can define middleware with generics:
import { TypedMiddleware } from "@minisylar/express-typed-router";
// Define typed middleware explicitly
const authMiddleware: TypedMiddleware<{ userId: string; isAdmin: boolean }> = (
req,
res,
next
) => {
const token = req.headers.authorization;
req.userId = "user123";
req.isAdmin = token?.includes("admin") || false;
next();
};
const loggingMiddleware: TypedMiddleware<{ requestId: string }> = (
req,
res,
next
) => {
req.requestId = Math.random().toString(36);
console.log(`[${req.requestId}] ${req.method} ${req.path}`);
next();
};
// Use the explicitly typed middleware
const router = createTypedRouter()
.useMiddleware(authMiddleware)
.useMiddleware(loggingMiddleware);
// Rest of the routes work the same way...TypeScript Features
For TypeScript users, the library provides advanced type safety features:
Typed Middleware
Define middleware that extends the request object with typed properties:
import { TypedMiddleware } from "@minisylar/express-typed-router";
const authMiddleware: TypedMiddleware<{ userId: string; isAdmin: boolean }> = (
req,
res,
next
) => {
req.userId = "user123";
req.isAdmin = true;
next();
};
// Add to router - types are automatically merged
router.useMiddleware(authMiddleware);
router.get("/protected", (req, res) => {
// TypeScript knows about req.userId and req.isAdmin
const { userId, isAdmin } = req;
res.json({ userId, isAdmin });
});Per-Route Middleware with Type Merging
const adminMiddleware: TypedMiddleware<{ hasAdminAccess: true }> = (
req,
res,
next
) => {
if (!req.isAdmin) {
return res.status(403).json({ error: "Admin required" });
}
req.hasAdminAccess = true;
next();
};
router.get(
"/admin/:userId",
{
middleware: [adminMiddleware], // No need for 'as const' - arrays are automatically typed
},
(req, res) => {
// Types from BOTH global and per-route middleware are available
const { userId } = req.params; // From route params
const { userId: authUserId } = req; // From global middleware
const { hasAdminAccess } = req; // From per-route middleware
res.json({ userId, authUserId, hasAdminAccess });
}
);Advanced Route Parameter Types
The library automatically infers complex Express route patterns:
// Express 5 repeating parameters
router.get("/files/:path+", (req, res) => {
const { path } = req.params; // string[] - automatically inferred!
});
// Optional parameters
router.get("/posts/:year/:month?", (req, res) => {
const { year, month } = req.params; // { year: string; month?: string }
});
// Complex patterns with separators
router.get("/flights/:from-:to", (req, res) => {
const { from, to } = req.params; // { from: string; to: string }
});Alternative API Styles
For developers who prefer setting up all middleware upfront:
import {
createTypedRouterWithMiddleware,
TypedMiddleware,
} from "@minisylar/express-typed-router";
const authMiddleware: TypedMiddleware<{ userId: string; isAdmin: boolean }> = (
req,
res,
next
) => {
req.userId = "user123";
req.isAdmin = true;
next();
};
const timestampMiddleware: TypedMiddleware<{ timestamp: Date }> = (
req,
res,
next
) => {
req.timestamp = new Date();
next();
};
// Create router with middleware - types are automatically merged
const router = createTypedRouterWithMiddleware(
authMiddleware,
timestampMiddleware
);
router.get("/protected", (req, res) => {
// req.userId, req.isAdmin, and req.timestamp are all typed correctly!
res.json({
userId: req.userId, // string
isAdmin: req.isAdmin, // boolean
timestamp: req.timestamp, // Date
});
});For applications that need custom error handling or configuration:
import { createTypedRouterWithConfig } from "@minisylar/express-typed-router";
const router = createTypedRouterWithConfig({
errorHandler: (error, req, res, next) => {
if (error.name === "ZodError") {
res.status(400).json({
error: "Validation failed",
details: error.errors,
});
} else {
next(error);
}
},
});
// Use normally
router.get("/users/:id", (req, res) => {
// Custom error handling is automatically applied
const { id } = req.params;
res.json({ id });
});Express 4 & 5 Route Pattern Support
This library provides complete TypeScript inference for all Express.js routing patterns across both Express 4 and 5:
Basic Patterns (Express 4 & 5)
// Named parameters
router.get("/users/:userId", handler);
// → { userId: string }
// Multiple parameters
router.get("/users/:userId/posts/:postId", handler);
// → { userId: string; postId: string }
// Parameters with separators
router.get("/flights/:from-:to", handler);
// → { from: string; to: string }
router.get("/files/:name.:ext", handler);
// → { name: string; ext: string }Advanced Patterns (Express 4)
// Optional parameters
router.get("/posts/:year/:month?", handler);
// → { year: string; month?: string }
// Regex constraints
router.get("/users/:id(\\d+)", handler);
// → { id: string }
// Wildcards
router.get("/files/*", handler);
// → { "0": string }
router.get("/api/*/files/*", handler);
// → { "0": string; "1": string }Express 5 Enhanced Patterns
// Repeating parameters (one or more)
router.get("/files/:path+", handler);
// → { path: string[] }
// Optional repeating (zero or more)
router.get("/search/:terms*", handler);
// → { terms?: string[] }
// Optional segments with braces
router.get("/api{/:version}/users", handler);
// → { version?: string }
router.get("/files{/:category}/:filename", handler);
// → { category?: string; filename: string }Real-World Examples
// E-commerce routes
router.get("/products/:category/:subcategory?", handler);
// → { category: string; subcategory?: string }
// File serving with optional versioning
router.get("/assets{/:version}/:filename.:ext", handler);
// → { version?: string; filename: string; ext: string }
// API versioning with wildcards
router.get("/api/v:version/*", handler);
// → { version: string; "0": string }
// Multi-segment paths (Express 5)
router.get("/docs/:sections+", handler);
// → { sections: string[] }All patterns work seamlessly with Zod validation and middleware type inference!
API Reference
createTypedRouter() - Main API
Creates a typed router instance with full flexibility for middleware and validation.
const router = createTypedRouter();
// Add global middleware (chainable)
router.useMiddleware(middleware1)
.useMiddleware(middleware2);
// All HTTP methods supported
router.get(path, options?, handler)
router.post(path, options?, handler)
router.put(path, options?, handler)
router.patch(path, options?, handler)
router.delete(path, options?, handler)
router.options(path, options?, handler)
router.head(path, options?, handler)
router.all(path, options?, handler)Route Options:
bodySchema: Zod schema for request body validationquerySchema: Zod schema for query parameter validationparamsSchema: Zod schema for route parameter validation (optional - auto-inferred from path)middleware: Array of typed middleware functions for this specific route
Examples:
// Simple route with auto-inferred params
router.get("/users/:id", (req, res) => {
const { id } = req.params; // string
});
// With body validation
router.post(
"/users",
{
bodySchema: z.object({ name: z.string() }),
},
(req, res) => {
const { name } = req.body; // string
}
);
// With per-route middleware
router.get(
"/admin",
{
middleware: [authMiddleware, adminMiddleware] as const,
},
(req, res) => {
// Types from both middleware are available
}
);TypedMiddleware<T>
Type for middleware functions that extend the request object.
const authMiddleware: TypedMiddleware<{ userId: string }> = (
req,
res,
next
) => {
req.userId = "123";
next();
};createTypedRouterWithConfig(config)
const router = createTypedRouterWithConfig({
errorHandler: (error, req, res, next) => {
// Custom error handling
},
});createTypedRouterWithMiddleware(...middleware)
const router = createTypedRouterWithMiddleware(middleware1, middleware2);Development
# Install dependencies
pnpm install
# Build the library
pnpm build
# Run type checking
pnpm type-check
# Build in watch mode
pnpm build:watchLicense
ISC
FAQ and Common Patterns
Middleware Behavior Differences from Express
IMPORTANT: Router Middleware and Route Registration
When using middleware with express-typed-router, there's an important difference from standard Express behavior:
In Express, middleware added with router.use() applies to all routes registered after it:
// Express middleware behavior
const router = express.Router();
router.use(authMiddleware); // Apply middleware
router.get("/route1", handler1); // Has authMiddleware
router.use(logMiddleware); // Apply another middleware
router.get("/route2", handler2); // Has BOTH auth and log middlewareIn express-typed-router, useMiddleware() returns a new router instance for type safety:
// ❌ WON'T WORK - middleware not applied to route
const router = createTypedRouter();
router.useMiddleware(authMiddleware); // Returns new router that isn't captured
router.get("/route", handler); // Original router without middleware!
// ✅ CORRECT - chain methods (recommended)
const router = createTypedRouter()
.useMiddleware(authMiddleware)
.get("/route", handler);
// ✅ CORRECT - chain directly from middleware call
const router = createTypedRouter();
router.useMiddleware(authMiddleware).get("/route", handler);
// ✅ ALSO CORRECT - use per-route middleware
const router = createTypedRouter();
router.get("/route", { middleware: [authMiddleware] }, handler);This design is necessary for full type safety but requires a different pattern than standard Express.
Common Express Patterns vs express-typed-router
Here are common Express patterns and how to achieve them with express-typed-router:
Pattern 1: Adding middleware to specific routes
Express:
const router = express.Router();
router.get("/public", publicHandler);
router.use(authMiddleware); // Only affects routes below
router.get("/private", privateHandler); // Has authMiddlewareexpress-typed-router:
// Option 1: Separate routers
const publicRouter = createTypedRouter();
publicRouter.get("/public", publicHandler);
const privateRouter = createTypedRouter().useMiddleware(authMiddleware);
privateRouter.get("/private", privateHandler);
// Combine in Express
app.use(publicRouter.getRouter());
app.use(privateRouter.getRouter());
// Option 2: Per-route middleware
const router = createTypedRouter();
router.get("/public", publicHandler);
router.get("/private", { middleware: [authMiddleware] }, privateHandler);Pattern 2: Adding middleware for a group of routes
Express:
const router = express.Router();
router.get("/public", handler);
// Only admin routes have auth middleware
const adminRouter = express.Router();
adminRouter.use(authMiddleware);
adminRouter.get("/users", adminHandler1);
adminRouter.get("/settings", adminHandler2);
router.use("/admin", adminRouter);express-typed-router:
const publicRouter = createTypedRouter();
publicRouter.get("/public", handler);
// Admin router with middleware
const adminRouter = createTypedRouter().useMiddleware(authMiddleware);
adminRouter.get("/users", adminHandler1);
adminRouter.get("/settings", adminHandler2);
// Combine with Express
app.use(publicRouter.getRouter());
app.use("/admin", adminRouter.getRouter());Pattern 3: Middleware with dynamically added routes
Express:
const router = express.Router();
router.use(middleware);
// Later, routes are added dynamically
function addRoute(path, handler) {
router.get(path, handler); // Has middleware
}express-typed-router:
// Option 1: Pass the router to the function
const router = createTypedRouter().useMiddleware(middleware);
function addRoute(router, path, handler) {
router.get(path, handler);
}
// Option 2: Factory function
function createRouteAdder(middleware) {
const router = createTypedRouter().useMiddleware(middleware);
return {
addRoute: (path, handler) => router.get(path, handler),
getRouter: () => router.getRouter(),
};
}
const routeAdder = createRouteAdder(middleware);
routeAdder.addRoute("/path", handler);
app.use(routeAdder.getRouter());Using express-typed-router in JavaScript
JavaScript users don't need to worry about TypeScript types but should still follow the middleware chaining pattern:
// JavaScript usage
const { createTypedRouter } = require("@minisylar/express-typed-router");
const router = createTypedRouter().useMiddleware((req, res, next) => {
req.user = { id: "user123" };
next();
});
router.get("/users", (req, res) => {
// req.user is available but not typed (JavaScript doesn't have types)
res.json({ userId: req.user.id });
});
module.exports = router.getRouter();Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
