Skip to content

Hands‑On Exercise: Build a Typed Plugin System Using Interfaces

We’ll create a tiny “application core” that loads plugins.

Each plugin must implement a shared interface, but can behave however it wants internally.

This is a perfect demonstration of:

  • enforcing contracts
  • polymorphism
  • interface‑driven architecture
  • class‑based plugin implementations

Step 1 — Define the Plugin Interface

Create:

plugin.ts

export interface Plugin {
  name: string
  initialize(): void
  execute(data: string): string
}

This interface defines the contract every plugin must follow.

  • name → identifies the plugin
  • initialize() → setup logic
  • execute() → transforms input data

Any plugin that doesn’t implement these correctly will fail at compile time.


Step 2 — Create the Plugin Manager

Create:

pluginManager.ts

import { Plugin } from "./plugin"

export class PluginManager {
  private plugins: Plugin[] = []

  register(plugin: Plugin) {
    this.plugins.push(plugin)
  }

  initializeAll() {
    for (const p of this.plugins) {
      p.initialize()
    }
  }

  runAll(data: string): string[] {
    return this.plugins.map(p => p.execute(data))
  }
}

This class:

  • stores plugins
  • initializes them
  • runs them in sequence
  • returns their outputs

Because of the interface, the manager doesn’t care which plugin it receives—only that it satisfies the contract.


Step 3 — Implement Concrete Plugins

Create:

uppercasePlugin.ts

import { Plugin } from "./plugin"

export class UppercasePlugin implements Plugin {
  name = "uppercase"

  initialize() {
    console.log("Uppercase plugin initialized")
  }

  execute(data: string) {
    return data.toUpperCase()
  }
}

Another plugin:

reversePlugin.ts

import { Plugin } from "./plugin"

export class ReversePlugin implements Plugin {
  name = "reverse"

  initialize() {
    console.log("Reverse plugin initialized")
  }

  execute(data: string) {
    return data.split("").reverse().join("")
  }
}

Both plugins implement the same interface, but behave differently.

This is polymorphism powered by TypeScript’s structural typing.


Step 4 — Use the Plugin System

Create:

index.ts

import { PluginManager } from "./pluginManager"
import { UppercasePlugin } from "./uppercasePlugin"
import { ReversePlugin } from "./reversePlugin"

const manager = new PluginManager()

manager.register(new UppercasePlugin())
manager.register(new ReversePlugin())

manager.initializeAll()

const results = manager.runAll("hello world")
console.log(results)

Run it:

Uppercase plugin initialized
Reverse plugin initialized
[ 'HELLO WORLD', 'dlrow olleh' ]

Your plugin system works.


Step 5 — Add a Third‑Party Plugin (Structural Typing Magic)

Here’s the twist:

A plugin does not need to explicitly implement the interface.

If it has the right shape, TypeScript accepts it.

customPlugin.ts

export const CustomPlugin = {
  name: "custom",
  initialize() {
    console.log("Custom plugin ready")
  },
  execute(data: string) {
    return `*** ${data} ***`
  }
}

Use it:

import { CustomPlugin } from "./customPlugin"

manager.register(CustomPlugin) // ✔ Works due to structural typing

This is incredibly powerful for plugin ecosystems.


Step 6 — Add Optional Capabilities (Advanced)

Let’s extend the interface with optional hooks.

Modify plugin.ts:

export interface Plugin {
  name: string
  initialize(): void
  execute(data: string): string
  cleanup?(): void
}

Plugins may implement cleanup, but don’t have to.

Update the manager:

shutdown() {
  for (const p of this.plugins) {
    p.cleanup?.()
  }
}

This uses optional chaining to call the hook only if it exists.


Step 7 — Add a Plugin That Uses cleanup

import { Plugin } from "./plugin"

export class LoggingPlugin implements Plugin {
  name = "logger"

  initialize() {
    console.log("Logger started")
  }

  execute(data: string) {
    console.log("Processing:", data)
    return data
  }

  cleanup() {
    console.log("Logger shutting down")
  }
}

Now the system supports lifecycle hooks—just like real plugin frameworks.


What You Learned

This hands‑on exercise demonstrates how TypeScript makes plugin architectures safe and expressive.

1. Interfaces enforce contracts

  • Plugins must implement required methods
  • Compile‑time errors catch missing or incorrect implementations

2. Classes provide structure

  • Encapsulation
  • Reusable logic
  • Clear initialization and execution flows

3. Structural typing enables flexible polymorphism

  • Plugins don’t need to explicitly implement the interface
  • Any object with the right shape works
  • Perfect for third‑party extensions

4. Optional members allow extensibility

  • Add new capabilities without breaking existing plugins
  • Real‑world pattern used in frameworks and libraries

This is exactly how real plugin systems are built in TypeScript ecosystems.