
Building Your Own Express-like Middleware System
Middleware is one of the most powerful concepts in Express.js, allowing you to chain functions together to handle requests in a modular way. It's a design pattern that enables separation of concerns, making your code more maintainable and reusable. In this post, I'll walk through how to build your own simplified middleware system that mimics Express.js middleware functionality.
Understanding Middleware
At its core, middleware is just a series of functions that have access to the request object, and a mechanism to pass control to the next middleware in the chain. In Express, middleware functions take three parameters: req
, res
, and next
. For our simplified version, we'll focus on just the request and the ability to move to the next function.
The TypeScript Interface
Let's start by defining our types:
type Request = object
type NextFunc = (error?: any) => void
type MiddlewareFunc = (req: Request, next: NextFunc) => void
type ErrorHandler = (error: Error, req: Request, next: NextFunc) => void
The Request
is just a generic object that middleware functions can modify. NextFunc
is a function that can optionally take an error parameter to signal that something went wrong. We have two types of functions: regular middleware functions (MiddlewareFunc
) and error handlers (ErrorHandler
).
Implementing the Middleware Class
Now, let's implement our Middleware
class:
class Middleware {
private middlewares: Array<MiddlewareFunc | ErrorHandler> = [];
use(func: MiddlewareFunc | ErrorHandler) {
this.middlewares.push(func);
return this;
}
start(req: Request) {
let index = 0;
const next: NextFunc = (error?: any) => {
// If there's an error, find the next error handler
if (error) {
while (index < this.middlewares.length) {
const handler = this.middlewares[index++];
if (handler.length === 3) { // Error handlers have 3 parameters
try {
(handler as ErrorHandler)(error, req, next);
} catch (e) {
next(e);
}
return;
}
}
// If no error handler is found, throw the error
console.error("Unhandled middleware error:", error);
return;
}
// No error, proceed to next middleware
if (index < this.middlewares.length) {
const middleware = this.middlewares[index++];
if (middleware.length === 2) { // Regular middleware has 2 parameters
try {
(middleware as MiddlewareFunc)(req, next);
} catch (e) {
next(e);
}
} else {
// Skip error handlers during normal flow
next();
}
}
};
next();
}
}
How It Works
Let's break down how this implementation works:
-
Storage: We maintain an array of middleware functions and error handlers.
-
Registration: The
use()
method adds a new function to our chain. -
Execution: The
start()
method kicks off the processing with an initial request object. -
The
next()
Function: This is the heart of the middleware system.- It checks if there's an error. If so, it looks for the next error handler.
- If there's no error, it calls the next regular middleware.
- If a middleware throws an exception, it catches it and passes it to the next error handler.
-
Error Handling: We differentiate between regular middleware and error handlers by checking the number of parameters the function expects (
handler.length
).
Example Usage
Let's look at a basic example:
const middleware = new Middleware();
middleware.use((req, next) => {
req.a = 1;
console.log("Middleware 1");
next();
});
middleware.use((req, next) => {
req.b = 2;
console.log("Middleware 2");
next();
});
middleware.use((req, next) => {
console.log("Final middleware:", req);
});
middleware.start({});
// Output:
// Middleware 1
// Middleware 2
// Final middleware: {a: 1, b: 2}
Error Handling Example
Now, let's see how error handling works:
const middleware = new Middleware();
middleware.use((req, next) => {
req.a = 1;
console.log("Middleware 1");
next(new Error("Something went wrong"));
});
middleware.use((req, next) => {
req.b = 2;
console.log("Middleware 2"); // This won't execute
next();
});
middleware.use((error, req, next) => {
console.log("Error handler:", error.message);
console.log("Request state:", req);
// We could either stop here or call next() to continue
// If we call next() without args, it will resume normal flow
// If we call next(error), it will look for the next error handler
});
middleware.start({});
// Output:
// Middleware 1
// Error handler: Something went wrong
// Request state: {a: 1}
Handling Asynchronous Operations
One of the powerful aspects of middleware is handling asynchronous operations. Our implementation supports this naturally:
middleware.use(async (req, next) => {
req.data = await fetchSomeData();
next();
});
However, there's a catch: if an async function throws an error, our try-catch
won't catch it unless we either use await
or handle the promise rejection. For a more robust solution, you could enhance the middleware system to handle Promises returned by middleware functions.
Limitations and Improvements
This simplified middleware system captures the essence of Express middleware, but it has limitations:
-
No Response Object: Our system only passes a request object, while Express passes both request and response.
-
Limited Error Propagation: Express has a more sophisticated error propagation system.
-
No Path-Based Middleware: Express allows you to specify middleware for specific paths.
To address these limitations, you could extend the system to include a response object, improve error handling, and add path matching functionality.
Conclusion
Building your own middleware system provides valuable insights into how Express.js works under the hood. While our implementation is simplified, it demonstrates the core concepts: function chaining, error handling, and control flow management.
The middleware pattern is incredibly versatile and can be applied in many contexts beyond web servers. Whether you're building a data processing pipeline, a validation system, or a plugin architecture, understanding middleware can help you create more modular and maintainable code.