Q34 of 38 · TypeScript

How would you design a typed plugin or extension system in TypeScript for a test framework?

TypeScriptSeniortypescriptplugin-systemmodule-augmentationgenericsarchitectureadvanced-types

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:

  1. The core framework declares an empty (or minimal) PluginMap interface.
  2. Each plugin augments PluginMap with its own entry: declare module "core" { interface PluginMap { analytics: AnalyticsPlugin } }
  3. 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

// WHAT INTERVIEWERS LOOK FOR

Module augmentation for open-ended extension without central registry. The generic registry pattern for compile-time type widening. Connecting to Playwright's `test.extend` as a real-world example of this pattern. This is an architectural senior signal.

// COMMON PITFALL

Returning `unknown` from `getPlugin` and requiring callers to cast — defeats the purpose. The whole value is that type narrowing happens in the registry, not at every call site.