Q34 of 38 · TypeScript
How would you design a typed plugin or extension system in TypeScript for a test framework?
Short answer
Short answer: Use a generic registry type with a constraint that plugins must implement a declared interface. Declaration merging via module augmentation lets each plugin register its own type into a shared map, giving consumers typed access to all registered plugins without a central registry file.
Detail
A typed plugin system allows third-party or team-contributed plugins to add capabilities to a core framework while maintaining full type safety across plugin boundaries.
Core concept — module augmentation for extension:
- The core framework declares an empty (or minimal)
PluginMapinterface. - Each plugin augments
PluginMapwith its own entry:declare module "core" { interface PluginMap { analytics: AnalyticsPlugin } } - The framework's
getPlugin<K extends keyof PluginMap>(name: K): PluginMap[K]method returns the correct type without runtime type assertions.
Playwright fixtures as a typed plugin system: test.extend<MyFixtures>({}) is exactly this pattern — the MyFixtures generic parameter augments the fixture type map, and test({ myFixture }, () => {}) receives a typed myFixture.
Generic registration pattern:
class PluginRegistry<T extends Record<string, unknown> = {}> {
register<K extends string, P>(name: K, plugin: P): PluginRegistry<T & Record<K, P>>
get<K extends keyof T>(name: K): T[K]
}
Each register call widens the registry's type parameter — callers that use get later receive the correct plugin type.
Use in custom test utilities: A command registry, custom reporter hook system, or configuration factory all benefit from this pattern.
// EXAMPLE
// core.ts — empty plugin map for augmentation
export interface PluginMap {}
const plugins = new Map<string, unknown>();
export function registerPlugin<K extends keyof PluginMap>(
name: K,
impl: PluginMap[K]
): void {
plugins.set(name, impl);
}
export function getPlugin<K extends keyof PluginMap>(name: K): PluginMap[K] {
return plugins.get(name) as PluginMap[K];
}
// analytics-plugin.ts
interface AnalyticsPlugin { track(event: string): void; }
declare module "./core" {
interface PluginMap {
analytics: AnalyticsPlugin;
}
}
registerPlugin("analytics", {
track(event) { console.log("track:", event); },
});
// consumer.ts
const analytics = getPlugin("analytics");
analytics.track("test-start"); // typed — no assertion needed
// analytics.nonexistent; // Error: not on AnalyticsPlugin