39.2 Plugin and Middleware Systems with Typed Extension Points
Right, so you want to build something that other people can extend. Maybe it’s a framework, a CLI tool, or a state management library. You’re smart enough to know you won’t think of every use case, so you decide to make it pluggable. This is where most developers reach for a void* in C or an any in TypeScript and call it a day. We are not most developers. We’re going to build an extension system that doesn’t make your users feel like they’re juggling chainsaws in the dark.
The core idea is simple: you provide a well-defined, typed extension point—a place in your code’s lifecycle where a plugin can hook in, do something, and potentially change the outcome. We’re going to look at two classic patterns for this: the simple lifecycle hook and the more powerful middleware pattern.
The Foundation: A Type-Safe Registry
Before plugins can exist, they need a place to live. You need a registry. But we’re not just going to push functions into an array and hope for the best. We’re going to use a Map and type it so fiercely that incorrect code will feel personally offended.
// First, define what a Plugin actually is. It's an object with a well-known property.
// This is vastly better than just a function because we can add metadata later without breaking changes.
interface MyToolPlugin {
// A unique identifier. Never, ever use this for logic. It's for debugging and unregistration.
name: string;
// The actual hook function. Let's say it's called after initialization.
onInit: (context: { toolConfig: ToolConfig }) => void;
}
// Now, let's create a world where only correctly typed plugins can exist.
class PluginRegistry {
private plugins = new Map<string, MyToolPlugin>();
register(plugin: MyToolPlugin) {
if (this.plugins.has(plugin.name)) {
throw new Error(`A plugin with the name "${plugin.name}" is already registered.`);
// Seriously, don't just overwrite it. That's how bugs hide.
}
this.plugins.set(plugin.name, plugin);
}
// Expose a way to run all the plugins at a specific hook.
triggerOnInit(context: { toolConfig: ToolConfig }) {
// Iterate over the values. The name key is for management, not execution order.
for (const plugin of this.plugins.values()) {
plugin.onInit(context); // Beautifully type-safe.
}
}
}
This is the basic blueprint. You define a contract (MyToolPlugin), and users fulfill it. The Map ensures no duplicate names, which is crucial for a sane ecosystem. Notice how the context object is passed to the hook. This is your plugin’s API. You control exactly what they can and cannot touch. Don’t just give them this. That’s a recipe for disaster.
Leveling Up: The Middleware Pattern
Lifecycle hooks are great for “fire and forget” actions. But what if you need plugins to intercept and transform data? You need middleware. Think of it like a pipeline or a chain of responsibility. Each function in the chain gets a crack at the input and a reference to the next function in line.
The classic example is a web framework like Express, but we can do far better with TypeScript. Let’s model a system that processes a request.
// Define the core data structure that flows through the middleware
interface RequestContext {
url: string;
body: unknown;
headers: Record<string, string>;
}
// The heart of the pattern: the Middleware function type.
// It takes a context and the next function in the chain.
type Middleware = (
context: RequestContext,
next: (context: RequestContext) => Promise<Response>
) => Promise<Response>;
class MiddlewareEngine {
private middleware: Middleware[] = [];
use(middleware: Middleware) {
this.middleware.push(middleware);
}
// The magic: composing the middleware array into a single function.
async handle(request: RequestContext): Promise<Response> {
// Create a copy of the stack. We're going to work through it recursively.
const stack = [...this.middleware];
// Define the next function. This is the tricky part.
const next = async (ctx: RequestContext): Promise<Response> => {
const layer = stack.shift(); // Grab the next middleware off the stack.
if (!layer) {
// If there's no more middleware, we've reached the "end" of the chain.
// This is where you'd typically send a 404 or a default response.
return new Response('Not Found', { status: 404 });
}
// Execute the current middleware layer, passing it the context and the *new* next function.
return layer(ctx, next);
};
// Kick everything off by calling the very first next.
return next(request);
}
}
Why is this so brilliant? Each middleware function controls whether to break the chain, continue down the rabbit hole by calling next, or even modify the context for everyone who comes after it. A logging middleware might call next and then log the response. An authentication middleware might break the chain and return a 401 immediately.
The Power of Context Augmentation
Here’s the million-dollar question: “How does my plugin add custom data to the context for downstream middleware to use?” You don’t let them mutate it willy-nilly. You use type augmentation.
// In your core library types:
interface CoreRequestContext {
url: string;
body: unknown;
}
// A plugin for authentication wants to add a 'user' object.
declare module './core' {
interface CoreRequestContext {
user?: { id: string; username: string }; // Make it optional because not every request will be authed.
}
}
// Your authentication middleware would then do:
const authMiddleware: Middleware = async (ctx, next) => {
const token = ctx.headers['Authorization'];
const user = await validateToken(token);
ctx.user = user; // This is now perfectly type-safe!
return next(ctx);
};
This is the TypeScript superpower. You define a baseline interface for your context, and plugins can use module augmentation to safely extend it. This keeps everything discoverable and prevents the context from turning into an untyped any-bag through (ctx as any).myThing = value.
The Inevitable Pitfalls
- Execution Order: Your plugins will depend on each other.
plugin-bwill need to run afterplugin-a. A simple array gives you zero control. For complex systems, you might need a priority number or a topological sort based on declared dependencies. This is hard. Good luck. - Error Handling: In the middleware pattern, you must wrap your
next()call in a try/catch. An error in one middleware should not break the entire chain uncontrollably. It should be caught and handled, perhaps by a dedicated error-handling middleware. - The
anySiren Call: It’s tempting to make the contextanyorRecord<string, unknown>to avoid complex typing. Resist. You will lose all Intellisense and type safety, which is the entire point of using TypeScript for this. Use module augmentation; it’s what it’s for.
The goal is to build a system that is powerful yet constrained, flexible yet predictable. It tells plugin authors, “Here are the rules. Play within this sandbox, and you can build anything. Break the rules, and the type system will break your build.” And that’s exactly how it should be.