Fluent, type-safe middleware orchestration for Express.js with TypeScript.
A modern approach to composing Express middleware with declarative chaining, built-in validation (Zod), rate limiting, caching, context management, and error handling.
- 🔗 Fluent API - Compose middleware with method chaining
- 🛡️ Type-Safe - Full TypeScript support with generic type inference
- ✅ Validation - Built-in Zod schema validation for body/query/params ✅
- ⚡ Rate Limiting - Per-endpoint rate limiting with pluggable stores ✅
- 💾 Caching - Response caching with tag-based invalidation (coming soon)
- 🔍 Request Context - AsyncLocalStorage-based request-scoped data ✅
- 🚨 Error Handling - Comprehensive error boundary middleware ✅
- 🗄️ Pluggable Stores - Abstract store interface with MemoryStore included ✅
- 🎯 Zero Dependencies - Core package has no runtime dependencies (peer deps only)
npm install express-middleware-chain zod
# or
yarn add express-middleware-chain zod
# or
pnpm add express-middleware-chain zodPeer Dependencies:
express^4.18.0 || ^5.0.0zod^3.20.0 (optional, required only if using validation)
import express from 'express';
import { chain } from 'express-middleware-chain';
import { z } from 'zod';
const app = express();
app.use(express.json());
// Define schemas
const userSchema = z.object({
email: z.string().email(),
name: z.string().min(2),
age: z.number().int().positive().optional()
});
const querySchema = z.object({
page: z.string().transform(Number).pipe(z.number().int().positive()).default('1'),
limit: z.string().transform(Number).pipe(z.number().int().max(100)).default('10')
});
// Create middleware chain
const userChain = chain()
.validate({ body: userSchema, query: querySchema })
.use((req, res, next) => {
// req.body and req.query are now typed!
console.log(`Creating user: ${req.body.email}`);
next();
})
.errorBoundary()
.build();
// Use in routes
app.post('/api/users', userChain, (req, res) => {
// Type-safe access to validated data
const { email, name, age } = req.body;
const { page, limit } = req.query;
res.json({
message: 'User created',
user: { email, name, age },
pagination: { page, limit }
});
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});Creates a new ChainBuilder instance to compose middleware.
import { chain } from 'express-middleware-chain';
const myChain = chain()
.validate({ body: schema })
.use(customMiddleware)
.errorBoundary()
.build();Validates request body, query parameters, and route parameters using Zod schemas.
import { z } from 'zod';
const schemas = {
body: z.object({ name: z.string() }),
query: z.object({ page: z.string() }),
params: z.object({ id: z.string().uuid() })
};
chain()
.validate(schemas)
.build();Response on validation error (400):
{
"errors": [
{
"location": "body",
"issues": [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": ["name"],
"message": "Required"
}
]
}
]
}Adds standard Express middleware to the chain.
chain()
.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next();
})
.use(authenticate)
.use(authorize(['admin']))
.build();All middleware added via .use() is automatically wrapped for async error handling.
Composes another chain into the current chain.
const authChain = chain()
.use(authenticate)
.use(authorize(['admin']));
const apiChain = chain()
.validate({ body: schema })
.compose(authChain) // Includes all middleware from authChain
.build();Adds rate limiting to protect your endpoints from abuse.
import { chain, MemoryStore } from 'express-middleware-chain';
// Basic rate limiting
chain()
.rateLimit({
limit: 100, // 100 requests
window: '15m' // per 15 minutes
})
.build();
// Advanced configuration
const store = new MemoryStore();
chain()
.rateLimit({
limit: 10,
window: '1m',
store, // Custom store
keyGenerator: (req) => req.user?.id, // Rate limit per user
message: 'Too many requests',
onLimitReached: (req, res) => {
console.log(`Rate limit exceeded for ${req.ip}`);
res.status(429).json({ error: 'Slow down!' });
}
})
.build();Configuration:
limit(number) - Maximum requests allowedwindow(string | number) - Time window ('15m', '1h', '1d' or milliseconds)store(Store) - Storage backend (default: shared MemoryStore)keyGenerator(function) - Custom key function (default: IP address)message(string) - Custom error messageskipFailedRequests(boolean) - Don't count 4xx/5xx responsesskipSuccessfulRequests(boolean) - Don't count 2xx/3xx responsesonLimitReached(function) - Custom handler when limit exceeded
Response Headers:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 2025-12-08T02:15:00.000Z
Retry-After: 123
Per-Endpoint Limiting:
Rate limits are automatically isolated per endpoint:
// Each endpoint has its own rate limit
app.get('/api/users', chain().rateLimit({ limit: 100, window: '15m' }).build(), handler);
app.post('/api/users', chain().rateLimit({ limit: 10, window: '15m' }).build(), handler);Adds an error boundary to catch and handle errors.
// Default error handler
chain()
.use(riskyMiddleware)
.errorBoundary()
.build();
// Custom error handler
chain()
.use(riskyMiddleware)
.errorBoundary((err, req, res, next) => {
console.error(err);
res.status(500).json({
error: 'Internal server error',
message: err.message
});
})
.build();Compiles the chain into an array of Express middleware handlers.
const handlers = chain()
.validate({ body: schema })
.use(middleware)
.build();
app.post('/endpoint', handlers, finalHandler);
// or spread directly
app.post('/endpoint', ...handlers, finalHandler);The RequestContext provides request-scoped data storage using Node.js AsyncLocalStorage.
import { RequestContext } from 'express-middleware-chain';
// Initialize context (do this once in your app)
app.use(RequestContext.init());
// Set values anywhere in your middleware chain
app.use((req, res, next) => {
RequestContext.set('user', { id: 123, role: 'admin' });
RequestContext.set('timestamp', Date.now());
next();
});
// Access values anywhere in the async context
app.get('/profile', (req, res) => {
const user = RequestContext.get<{ id: number; role: string }>('user');
const timestamp = RequestContext.get<number>('timestamp');
res.json({ user, timestamp });
});
// Get auto-generated request ID
const requestId = RequestContext.getRequestId();Features:
- ✅ Automatic request ID generation (UUID v4)
- ✅ Type-safe get/set operations
- ✅ Works across async boundaries
- ✅ Zero global state pollution
Requirements: Node.js 16+ (for AsyncLocalStorage)
The package includes a pluggable store system for rate limiting and caching state.
Built-in in-memory store for single-process applications:
import { MemoryStore } from 'express-middleware-chain';
const store = new MemoryStore({
cleanupIntervalMs: 60000, // Cleanup every minute
maxSize: 10000 // Max 10,000 entries (LRU eviction)
});
// Use with rate limiting
chain()
.rateLimit({ limit: 100, window: '15m', store })
.build();Features:
- ✅ TTL support with automatic expiration
- ✅ Tag-based invalidation
- ✅ Pattern matching for bulk deletion
- ✅ Max size limits with LRU-like eviction
- ✅ Periodic cleanup of expired entries
Store Methods:
// Get/Set with TTL
await store.set('key', { data: 'value' }, 60000); // 60 second TTL
const value = await store.get('key');
// Rate limiting
const result = await store.increment('counter', 60000);
console.log(result.count, result.resetAt);
// Tag-based invalidation
await store.set('user:1', { data: user }, 3600000, { tags: ['users'] });
await store.invalidateByTag('users'); // Clear all 'users' entries
// Pattern matching
await store.clear('user:*'); // Clear all keys starting with 'user:'
// Cleanup
store.destroy(); // Stop cleanup intervalImplement the Store interface for Redis, database, or other backends:
import type { Store, StoreValue, RateLimitResult } from 'express-middleware-chain';
class RedisStore implements Store {
async get(key: string): Promise<StoreValue | undefined> {
// Implementation
}
async set(key: string, value: StoreValue, ttl?: number): Promise<void> {
// Implementation
}
async increment(key: string, ttl?: number): Promise<RateLimitResult> {
// Implementation
}
async delete(key: string): Promise<void> {
// Implementation
}
async invalidateByTag(tag: string): Promise<void> {
// Implementation
}
async clear(pattern?: string): Promise<void> {
// Implementation
}
}const validationChain = chain()
.validate({
body: userSchema,
query: paginationSchema
});
const authChain = chain()
.use(authenticate)
.use(authorize(['admin', 'moderator']));
const loggingChain = chain()
.use((req, res, next) => {
console.log(`[${RequestContext.getRequestId()}] ${req.method} ${req.path}`);
next();
});
// Compose them together
const fullChain = chain()
.compose(loggingChain)
.compose(validationChain)
.compose(authChain)
.errorBoundary()
.build();
app.post('/api/users', fullChain, createUserHandler);The validation method updates the generic types to reflect validated schemas:
import { z } from 'zod';
const bodySchema = z.object({
email: z.string().email(),
age: z.number().int()
});
const myChain = chain()
.validate({ body: bodySchema })
.use((req, res, next) => {
// TypeScript knows req.body has { email: string; age: number }
const email: string = req.body.email; // ✅ Type-safe
const age: number = req.body.age; // ✅ Type-safe
next();
})
.build();import type { RequestHandler } from 'express';
interface LoggerConfig {
prefix?: string;
timestamp?: boolean;
}
function createLogger(config: LoggerConfig = {}): RequestHandler {
const { prefix = 'LOG', timestamp = true } = config;
return (req, res, next) => {
const time = timestamp ? new Date().toISOString() : '';
console.log(`[${prefix}] ${time} ${req.method} ${req.path}`);
next();
};
}
// Use it
chain()
.use(createLogger({ prefix: 'API', timestamp: true }))
.build();src/
├── index.ts # Public API exports
├── types.ts # Shared type definitions
├── chain/ # Core chain builder
│ ├── ChainBuilder.ts # Fluent API implementation
│ └── index.ts
├── middleware/ # Built-in middleware
│ ├── validation.ts # Zod validation (✅ implemented)
│ ├── errorBoundary.ts # Error handling (✅ implemented)
│ ├── rateLimit.ts # Rate limiting (✅ implemented)
│ ├── cache.ts # Response caching (🚧 planned)
│ └── index.ts
├── context/ # Request context
│ └── RequestContext.ts # AsyncLocalStorage wrapper (✅ implemented)
├── stores/ # Store implementations (✅ implemented)
│ ├── Store.ts # Store interface
│ ├── MemoryStore.ts # Built-in in-memory store
│ └── index.ts
└── utils/ # Helper utilities (✅ implemented)
├── duration.ts # Parse duration strings
├── keyGenerator.ts # Cache/rate limit key utilities
└── index.ts
See AGENTS.md for the full development roadmap and implementation status.
- Core ChainBuilder with fluent API
- Zod validation middleware
- Error boundary middleware
- Request context (AsyncLocalStorage)
- Type-safe generic inference
- Chain composition
- Rate limiting middleware (NEW in v0.1.0)
- Store interface and MemoryStore (NEW in v0.1.0)
- Duration parsing utilities (NEW in v0.1.0)
- Key generation utilities (NEW in v0.1.0)
- Response caching middleware
- Redis store adapter
- Transform middleware
- Conditional execution (
.when()) - Middleware groups/presets
- Performance monitoring hooks
- OpenTelemetry integration
This package uses Vitest for testing.
# Run tests
pnpm test
# Run tests in watch mode
pnpm test:watch
# Type check
pnpm typecheck
# Build
pnpm build- Node.js: 16+ (for AsyncLocalStorage)
- TypeScript: 5.0+ (recommended)
- Express: 4.18+ or 5.0+
This package supports both Express 4 and Express 5:
- Express 4: Primary target, widest adoption
- Express 5: Native Promise support, tested and compatible
The package uses feature detection rather than version checking for compatibility.
For best results, use strict TypeScript settings:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"moduleResolution": "bundler",
"target": "ES2022"
}
}Contributions are welcome! Please see AGENTS.md for development guidelines and architecture details.
# Clone the repository
git clone https://github.com/iAn-P1nt0/express-middleware-chain.git
cd express-middleware-chain
# Install dependencies
pnpm install
# Run tests
pnpm test
# Build
pnpm buildMIT © Ian Pinto
Inspired by the need for better middleware composition patterns in Express.js applications and the type-safety benefits of Zod and TypeScript.
- 🐛 Report Issues
- 💬 Discussions
- 📧 Contact: Create an issue