Skip to Content
Course content

239: Typing a Plugin Registry

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Imagine you're building a high-end modular synthesizer. The synth has a main chassis with several empty slots. You don't know exactly which modules a musician will buy—maybe a filter, an oscillator, or a sequencer—but you do know that any module plugged into the "Filter" slot must have a specific set of knobs and voltage inputs, otherwise, it'll fry the circuit.

The chassis is your Plugin Registry. It doesn't care about the specific brand or model of the module; it just cares that if something claims to be a "Filter," it adheres to the "Filter" interface. If you try to jam a sequencer module into a filter slot, the physical shape (the type system) should prevent it from fitting.

In TypeScript, we achieve this by creating a registry that maps specific keys (the slots) to specific interfaces (the module requirements).

Defining the Plugin Blueprint

First, we need to define what our plugins actually look like. Let's say we're building a payment processing system. We might have different providers like Stripe or PayPal, but each must implement a common set of methods.

interface PaymentProvider {
  processPayment(amount: number): Promise<boolean>;
}

interface RefundProvider {
  refundPayment(transactionId: string): Promise<boolean>;
}

// This is our "Slot Map"
interface PluginSchema {
  payments: PaymentProvider;
  refunds: RefundProvider;
}

I like to keep this schema separate. It acts as the single source of truth for what the system supports. If you want to add a "TaxCalculator" plugin later, you just add one line to this interface.

Building the Type-Safe Registry

Now, here is where most people trip up. If you just use a Map<string, any>, you've thrown away all the benefits of TypeScript. We want the registry to know that if we ask for the 'payments' plugin, we are getting a PaymentProvider, not a RefundProvider.

We can do this using generics and indexed access types:

class PluginRegistry<T extends Record<string, any>> {
  private plugins = new Map<string, any>();

  // We use K extends keyof T to ensure only valid slots are used
  register<K extends keyof T>(id: K, plugin: T[K]): void {
    this.plugins.set(id as string, plugin);
    console.log(`Registered plugin for slot: ${id as string}`);
  }

  get<K extends keyof T>(id: K): T[K] {
    const plugin = this.plugins.get(id as string);
    if (!plugin) {
      throw new Error(`Plugin ${id as string} not found!`);
    }
    return plugin;
  }
}

Notice how T[K] works here. It's telling TypeScript: "Look at the schema T and find the type associated with the key K." It's the secret sauce that gives us perfect autocomplete and compile-time errors.

Putting it into Practice

When we instantiate the registry, we pass in our PluginSchema. From that point on, the registry is locked into those specific types.

const registry = new PluginRegistry<PluginSchema>();

const stripePlugin: PaymentProvider = {
  processPayment: async (amt) => {
    console.log(`Charging $${amt} via Stripe...`);
    return true;
  }
};

// This works perfectly
registry.register('payments', stripePlugin);

// This would cause a TypeScript error because 'refunds' expects a RefundProvider
// registry.register('refunds', stripePlugin); 

const paymentSvc = registry.get('payments');
paymentSvc.processPayment(100); // TypeScript knows this is a PaymentProvider

I've found that this pattern is incredibly useful for large-scale applications where you want to decouple the core logic from the implementation details. You can ship the PluginRegistry and the PluginSchema in a core package, and let other teams write the actual plugin implementations in separate libraries.




📋 Practical Task

Exercise: Implementing a Custom Data Exporter Registry

You are building a data reporting tool. The tool needs to be able to export data in different formats (CSV, JSON, and PDF). Each exporter has a different configuration requirement: CSV needs a delimiter, JSON needs a prettyPrint boolean, and PDF needs a pageSize string.

Your task:

  • Define three interfaces: CSVExporter, JSONExporter, and PDFExporter. Each should have an export(data: any): void method and their specific configuration property.
  • Create a ExporterSchema interface that maps the keys 'csv', 'json', and 'pdf' to these interfaces.
  • Implement a Registry class (similar to the one in the lesson) that uses generics to ensure that only the correct exporter can be registered to the correct key.
  • Instantiate the registry and register a valid JSONExporter.
  • Attempt to register a CSVExporter to the 'json' key and verify that TypeScript throws a type error.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.