Building a Plugin System in TypeScript
Prerequisites
- TypeScript 5+
- Understanding of interfaces and generics
- Familiarity with async patterns
- A mug of something caffeinated (non-negotiable)
- At least one mass extinction event’s worth of patience for debugging generics
- A rubber duck, ideally one that doesnt judge
What We’re Building
A type-safe plugin architecture that lets external code extend your application. Lifecycle hooks, dependency injection, configuration validation. All the good stuff, none of the “oh god what has this plugin done to my state” energy.
If you’ve ever worked on a codebase where someone just monkey-patched the prototype chain to add extensibility, first of all, I’m sorry. Second, this post is for you.

The Approach
- Define the plugin interface
- Create a plugin manager
- Add lifecycle hooks
- Implement dependency injection
- Handle plugin configuration
Straightforward enough. Famous last words.
Step 1: Define the Plugin Interface
Every plugin system starts with a contract. Ours needs to declare what a plugin is, what it can do, and how we track its its lifecycle. TypeScript interfaces are perfect here because they enforce the contract at compile time without adding runtime overhead.
// types/plugin.ts
export interface PluginContext {
config: AppConfig;
logger: Logger;
events: EventEmitter;
}
export interface Plugin {
name: string;
version: string;
dependencies?: string[];
setup(context: PluginContext): Promise<void> | void;
teardown?(): Promise<void> | void;
}
export interface PluginMetadata {
name: string;
version: string;
loadedAt: Date;
status: 'pending' | 'active' | 'failed' | 'disabled';
error?: Error;
}
The PluginContext is what gets handed to every plugin on setup. It’s the plugin’s window into the application. Notice teardown is optional because not every plugin needs cleanup, but the ones that do really need it. Leaking event listeners isnt a hobby you want to pick up.
Step 2: Create the Plugin Manager
This is where it gets interesting. The plugin manager handles registration, dependency ordering, and lifecycle management. It’s essentially a tiny orchestrator that makes sure plugins load in the right order and fail loudly when they dont.
// lib/plugin-manager.ts
import type { Plugin, PluginContext, PluginMetadata } from './types';
export class PluginManager {
private plugins: Map<string, Plugin> = new Map();
private metadata: Map<string, PluginMetadata> = new Map();
private context: PluginContext;
constructor(context: PluginContext) {
this.context = context;
}
register(plugin: Plugin): void {
if (this.plugins.has(plugin.name)) {
throw new Error(`Plugin "${plugin.name}" is already registered`);
}
this.plugins.set(plugin.name, plugin);
this.metadata.set(plugin.name, {
name: plugin.name,
version: plugin.version,
loadedAt: new Date(),
status: 'pending',
});
}
async loadAll(): Promise<void> {
const sorted = this.sortByDependencies();
for (const name of sorted) {
await this.load(name);
}
}
private async load(name: string): Promise<void> {
const plugin = this.plugins.get(name);
const meta = this.metadata.get(name);
if (!plugin || !meta) {
throw new Error(`Plugin "${name}" not found`);
}
this.context.logger.info(`Loading plugin: ${name}@${plugin.version}`);
try {
await plugin.setup(this.context);
meta.status = 'active';
this.context.events.emit('plugin:loaded', { name });
} catch (error) {
meta.status = 'failed';
meta.error = error as Error;
this.context.logger.error(`Failed to load plugin "${name}":`, error);
throw error;
}
}
private sortByDependencies(): string[] {
const sorted: string[] = [];
const visited = new Set<string>();
const visit = (name: string, ancestors: Set<string> = new Set()) => {
if (ancestors.has(name)) {
throw new Error(`Circular dependency detected: ${name}`);
}
if (visited.has(name)) return;
const plugin = this.plugins.get(name);
if (!plugin) return;
ancestors.add(name);
for (const dep of plugin.dependencies ?? []) {
visit(dep, ancestors);
}
ancestors.delete(name);
visited.add(name);
sorted.push(name);
};
for (const name of this.plugins.keys()) {
visit(name);
}
return sorted;
}
async unloadAll(): Promise<void> {
const reversed = [...this.plugins.keys()].reverse();
for (const name of reversed) {
const plugin = this.plugins.get(name);
if (plugin?.teardown) {
await plugin.teardown();
this.context.events.emit('plugin:unloaded', { name });
}
}
}
getMetadata(): PluginMetadata[] {
return [...this.metadata.values()];
}
}
The dependency sorting uses a topological sort with cycle detection. If plugin A depends on plugin B which depends on plugin A, we throw immediately rather than getting stuck in an infinite loop. Your CPU will thank you.
The unloadAll method reverses the load order, which is important. If plugin B was loaded after plugin A because it depends on it, you want to tear down B before A. Think of it like unstacking plates. You dont start from the bottom.

Step 3: Add Lifecycle Hooks
Plugins need to tap into application events without the application knowing (or caring) what plugins are installed. The hook system is essentially a typed middleware pipeline.
// types/hooks.ts
export interface PluginHooks {
'request:before': (req: Request) => Request | Promise<Request>;
'request:after': (req: Request, res: Response) => Response | Promise<Response>;
'error': (error: Error) => void;
}
export type HookName = keyof PluginHooks;
The PluginHooks interface is the key bit. It maps hook names to their handler signatures, so when you register a handler for request:before, TypeScript knows it must accept a Request and return a Request. No guessing, no runtime surprises.
// lib/hook-registry.ts
export class HookRegistry {
private hooks: Map<string, Set<Function>> = new Map();
register<K extends HookName>(
name: K,
handler: PluginHooks[K]
): () => void {
if (!this.hooks.has(name)) {
this.hooks.set(name, new Set());
}
this.hooks.get(name)!.add(handler);
return () => {
this.hooks.get(name)?.delete(handler);
};
}
async run<K extends HookName>(
name: K,
...args: Parameters<PluginHooks[K]>
): Promise<ReturnType<PluginHooks[K]> | undefined> {
const handlers = this.hooks.get(name);
if (!handlers) return args[0] as any;
let result = args[0];
for (const handler of handlers) {
result = await (handler as Function)(result, ...args.slice(1));
}
return result as any;
}
}
Notice that register returns a cleanup function. This is the Dispose pattern in action. The caller can unregister the hook whenever they like without needing to hold a reference to the registry. It’s a small thing, but it makes teardown significantly cleaner.
The run method chains handlers sequentially, passing each handler’s output as the next handler’s input. This is the middleware pattern you’ve seen in Express, Koa, and roughly four thousand npm packages.
Step 4: Plugin Configuration
Now we need to give plugins their own configuration namespace. Each plugin can declare defaults, validate its config, and access it through the context.
// types/plugin.ts
export interface PluginWithConfig<T = unknown> extends Plugin {
defaultConfig?: T;
validateConfig?(config: T): boolean | string;
}
export interface PluginContext {
config: AppConfig;
getPluginConfig<T>(name: string): T;
logger: Logger;
events: EventEmitter;
hooks: HookRegistry;
}
The validateConfig method returns either true or an error message string. Simple, composable, and it means plugin authors dont need to import any validation library.
// Usage in plugin manager
getPluginConfig<T>(name: string): T {
return this.context.config.plugins?.[name] as T ?? {};
}
That generic T does the heavy lifting. The plugin author knows what shape their config is, and getPluginConfig<AnalyticsConfig>('analytics') gives them a fully typed object back. No any, no casting at the call site.
Step 5: Create Example Plugins
Theory is lovely but let’s see it in practice. Here’s a logger plugin that hooks into the request lifecycle.
// plugins/logger-plugin.ts
import type { Plugin, PluginContext } from '../types';
export const loggerPlugin: Plugin = {
name: 'logger',
version: '1.0.0',
setup(context) {
context.hooks.register('request:before', (req) => {
context.logger.info(`Incoming: ${req.method} ${req.url}`);
return req;
});
context.hooks.register('request:after', (req, res) => {
context.logger.info(`Completed: ${req.method} ${req.url} - ${res.status}`);
return res;
});
context.hooks.register('error', (error) => {
context.logger.error('Unhandled error:', error);
});
},
};
And an analytics plugin that depends on the logger (because you want to see what’s being tracked).
// plugins/analytics-plugin.ts
interface AnalyticsConfig {
trackingId: string;
sampleRate: number;
}
export const analyticsPlugin: PluginWithConfig<AnalyticsConfig> = {
name: 'analytics',
version: '1.0.0',
dependencies: ['logger'],
defaultConfig: {
trackingId: '',
sampleRate: 1.0,
},
validateConfig(config) {
if (!config.trackingId) return 'trackingId is required';
if (config.sampleRate < 0 || config.sampleRate > 1) {
return 'sampleRate must be between 0 and 1';
}
return true;
},
setup(context) {
const config = context.getPluginConfig<AnalyticsConfig>('analytics');
context.hooks.register('request:after', (req, res) => {
if (Math.random() <= config.sampleRate) {
trackPageView(config.trackingId, req.url);
}
return res;
});
},
};
Notice the dependencies: ['logger'] declaration. The plugin manager will ensure the logger plugin loads first. If you try to register analytics without logger, the dependency sort will catch it. No silent failures, no mystery bugs at 3am.
Step 6: Wire It Together
Here’s where everything comes together. Create the context, register your plugins, load them up, and start handling requests through the hook pipeline.
// app.ts
import { PluginManager } from './lib/plugin-manager';
import { HookRegistry } from './lib/hook-registry';
import { loggerPlugin } from './plugins/logger-plugin';
import { analyticsPlugin } from './plugins/analytics-plugin';
const hooks = new HookRegistry();
const context: PluginContext = {
config: loadConfig(),
logger: console,
events: new EventEmitter(),
hooks,
getPluginConfig: (name) => context.config.plugins?.[name] ?? {},
};
const plugins = new PluginManager(context);
plugins.register(loggerPlugin);
plugins.register(analyticsPlugin);
await plugins.loadAll();
async function handleRequest(req: Request): Promise<Response> {
try {
req = await hooks.run('request:before', req);
const res = await processRequest(req);
return await hooks.run('request:after', req, res);
} catch (error) {
hooks.run('error', error as Error);
throw error;
}
}
That handleRequest function has no idea what plugins are installed. It just runs hooks and processes requests. That’s the whole point. You can add, remove, or swap plugins without touching the core application code.

The Result
What we’ve built gives you:
- Type-safe plugin interface so plugin authors get autocompletion and compile-time checks
- Automatic dependency ordering via topological sort with cycle detection
- Lifecycle management with clean setup and teardown
- Configuration validation that fails fast with clear error messages
- Extensible hook system using the middleware pattern

What I’d Do Differently
Add plugin sandboxing. Plugins can currently do anything, which is fine for trusted code but risky for third-party plugins. Consider running plugins in isolated contexts, perhaps using Node’s vm module or even separate worker threads. The performance cost is real, but so is the cost of a rogue plugin overwriting Array.prototype. Ask me how I know.
Build the plugin system you wish you’d inherited. Your future self will quietly appreciate it.