Skip to Content
Course content

83: Building a Typed Plugin System

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

I've seen this happen in countless pull requests: a developer wants to build a plugin system, and their first instinct is to define a Plugin interface with a method like execute(data: any): any. They tell themselves, "I'll just cast the result to the type I need once it comes out of the plugin."

The "Any" Shortcut is a Runtime Time-Bomb

The problem with using any in a plugin system is that you aren't actually building a typed system; you're just building a JavaScript system and putting a TypeScript wrapper around it. You lose the very thing that makes TS valuable: the guarantee that the data flowing from Plugin A into Plugin B is actually what Plugin B expects.

// The "Wrong" Way
interface Plugin {
  name: string;
  execute(data: any): any; 
}

const plugins: Plugin[] = [
  { name: 'UpperCase', execute: (d) => d.toUpperCase() },
  { name: 'LengthCounter', execute: (d) => d.length }
];

// This looks fine to TS, but if the order changes, 
// the 'UpperCase' plugin will try to call .toUpperCase() on a number.
// BOOM. Runtime error.
plugins.reduce((acc, plugin) => plugin.execute(acc), "hello");

If you rely on any, you're essentially playing Russian Roulette with your data pipeline. You might get lucky in development, but your users will find the edge cases where a plugin returns an unexpected shape and crashes the entire application.

Defining Type-Safe Contracts with Generics

To do this right, we need the plugin system to be aware of the transformation happening. Instead of a generic "execute" method, we define our plugins using generics. This allows each plugin to declare exactly what it consumes and what it produces, while still adhering to a common structure.

Let's imagine we're building a Log Processing Engine. We want plugins that can filter logs, transform them, or enrich them with metadata.

interface Plugin<TInput, TOutput> {
  name: string;
  process(input: TInput): TOutput;
}

// A plugin that takes a raw string log and returns a structured object
const LogParser: Plugin<string, { message: string; timestamp: number }> = {
  name: 'Parser',
  process: (input) => ({
    message: input,
    timestamp: Date.now(),
  }),
};

// A plugin that takes that object and adds a severity level
const SeverityTagger: Plugin<{ message: string; timestamp: number }, { message: string; timestamp: number; level: string }> = {
  name: 'Tagger',
  process: (input) => ({
    ...input,
    level: input.message.includes('ERROR') ? 'high' : 'low',
  }),
};

By using TInput and TOutput, we've created a contract. TypeScript can now verify that the output of LogParser matches the input of SeverityTagger. If I tried to swap their order, the compiler would scream at me immediately.

Managing a Heterogeneous Plugin Registry

Now, here is where it gets tricky. You usually don't just have one plugin; you have a list of them. But if every plugin has different generic types, a simple array like Plugin<any, any>[] puts us right back where we started with any.

The secret is to create a "Pipeline" wrapper. The pipeline doesn't care about the internal types of every single plugin; it only cares that the start of the chain matches the initial input and the end matches the final output. I personally prefer using a builder pattern for this to maintain type safety across the chain.

class PluginPipeline<TCurrent> {
  constructor(private pipeline: ((input: TCurrent) => TCurrent) = (i) => i) {}

  pipe<TNext>(plugin: Plugin<TCurrent, TNext>): PluginPipeline<TNext> {
    return new PluginPipeline<TNext>((input) => {
      const result = this.pipeline(input as any); // Internal cast is safe here because of the signature
      return plugin.process(result);
    });
  }

  execute(input: TCurrent): any {
    return this.pipeline(input);
  }
}

// Usage
const myPipeline = new PluginPipeline<string>()
  .pipe(LogParser)
  .pipe(SeverityTagger);

const finalLog = myPipeline.execute("ERROR: System failure"); 
// finalLog is now typed as { message: string; timestamp: number; level: string }

Notice how the pipe method returns a PluginPipeline<TNext>. This "shifts" the type of the pipeline forward. It's a bit of a dance with the type system, but it ensures that you can't accidentally plug a "Number-to-String" plugin into a "Boolean-to-Date" slot.




📋 Practical Task

Exercise: Implementing a Typed Image Processing Pipeline

You are building a tool that processes image metadata. You need to create a type-safe plugin system where images move through a series of transformations.

Requirements:

  • Define a MetadataPlugin<TIn, TOut> interface with a transform(input: TIn): TOut method.
  • Create three specific plugins:
    1. RawToBasic: Takes a string (raw file path) and returns an object { path: string, size: number }.
    2. BasicToDetailed: Takes the basic object and adds { dimensions: { w: number, h: number } }.
    3. DetailedToJson: Takes the detailed object and returns a string (JSON representation).
  • Implement a Pipeline class similar to the one in the lesson that allows these plugins to be chained using a .add() method.
  • Ensure that if you try to add RawToBasic after DetailedToJson, TypeScript throws a compilation error.
  • Execute the pipeline starting with the string "image.jpg" and verify the final output is a string.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.