Towards a well-typed plugin architecture
declare abstract class EnginePlugin<I = unknown, D = unknown> {
createInterface?(ø: Record<string, unknown>): I
getDependencies?(): D
}
type Defined<T> = T extends undefined ? never : T
type ExtractPlugins<T> = T extends Engine<infer PX> ? PX : never
type UnionToIntersection<U> = (
U extends unknown ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never
type MergeInterfaces<
E extends Engine,
K extends keyof EnginePlugin,
> = UnionToIntersection<ReturnType<Defined<ExtractPlugins<E>[number][K]>>>
type Assume<T, U> = T extends U ? T : never
type GetDependencies<P extends EnginePlugin> = Assume<
P extends EnginePlugin<unknown, infer D> ? D : never,
EnginePlugin[]
>
type PluginDependencyErrorMessage =
`Plugin is missing one or more dependencies.`
type EnforceDependencies<
E extends Engine,
P extends EnginePlugin,
> = GetDependencies<P>[number] extends ExtractPlugins<E>[number]
? P
: PluginDependencyErrorMessage
declare class Engine<PX extends EnginePlugin[] = []> {
registerPlugin<P extends EnginePlugin>(
plugin: EnforceDependencies<this, P>,
): asserts this is Engine<[...PX, P]>
createInterface(): MergeInterfaces<this, "createInterface">
}
interface DogInterface {
bark(): void
}
declare const DogPlugin: {
new (): {
createInterface(ø: Record<string, unknown>): DogInterface
}
(ø: unknown): ø is DogInterface
}
interface CatInterface {
meow(message: string): void
}
declare const CatPlugin: {
new (): {
super(): typeof CatPlugin
createInterface(ø: Record<string, unknown>): CatInterface
}
(ø: unknown): ø is CatInterface
}
interface PantherInterface {
panther: {
roar(): void
}
}
declare const PantherPlugin: {
new (): {
createInterface(ø: Record<string, unknown>): PantherInterface
}
getDependencies(): [typeof CatPlugin]
(ø: unknown): ø is PantherInterface
}
declare const engine: Engine
engine.registerPlugin(new DogPlugin())
engine.registerPlugin(new CatPlugin())
engine.registerPlugin(new PantherPlugin())
const ø = engine.createInterface()
ø.bark()
ø.meow("hello")
ø.panther.roar()
ø.meow("meow")
if (DogPlugin(ø)) {
ø.bark()
}
Towards a well-typed plugin architecture
Introduction
There are many reasons why one might want to develop a plugin architecture. First and foremost amongst these is extensibility: the ability to add new functionality to a system without having to modify the existing codebase. This can be a very powerful tool, allowing developers to ship functionality as and when it is ready, rather than being forced to wait for a major release.
A well-designed plugin architecture can also promote code reuse. By encapsulating functionality within a plugin, it can be easily reused in other projects.
However, designing a good plugin architecture is not a trivial task. There are many issues to consider, such as how to manage dependencies between plugins, how to ensure that plugins can communicate with each other, and how to handle upgrades and downgrades.
In this article, we will explore one approach to designing a plugin architecture using the TypeScript programming language. We will see how to use TypeScript’s static type-checking to enforce dependencies between plugins, and how to generate type-safe APIs for communication between plugins.
An abstract EnginePlugin
definition
We will start by defining an abstract EnginePlugin
class. This class will
serve as the base class for all plugins that we develop.
abstract class EnginePlugin<I = unknown, D = unknown> {
createInterface?(ø: Record<string, unknown>): I
getDependencies?(): D
}
The EnginePlugin
class defines two abstract methods, createInterface
and
getDependencies
. The createInterface
method is responsible for creating an
instance of the plugin’s interface. This instance will be used by the
application to access the functionality provided by the plugin.
The getDependencies
method is responsible for returning an array of
dependencies that the plugin has on other plugins. These dependencies will be
used to ensure that the plugin is only loaded if all of its dependencies are
satisfied.
Why ø ?
We are using ø as a variable identifier to denote the central ‘composite interface’ as a result of total plugin registration. ø is convenient as it’s readily accessible on OSX keyboards [⌥+o] and is visually unique. Unicode identifiers can be controversial.
Extending EnginePlugin
Now that we have defined the EnginePlugin
class, we can start to develop
some concrete plugins. Let’s start by developing a plugin that provides a
DogInterface
. This interface will allow us to make a dog bark.
interface DogInterface {
bark(): void
}
const DogPlugin: {
new (): {
createInterface(ø: Record<string, unknown>): DogInterface
}
(ø: unknown): ø is DogInterface
}
Our DogPlugin
is a simple JavaScript object that contains two properties.
The first is a constructor function that creates an instance of the plugin’s
interface. The second is a function that allows us to check if an object is an
instance of the plugin’s interface.
Now that we have developed our plugin, we can use it to create an instance of
the DogInterface
.
const dog = new DogPlugin().createInterface()
dog.bark() // "woof!"
Managing plugin dependencies
Now that we have developed our first plugin, we can start to develop a second
plugin that depends on the first. Let’s develop a plugin that provides a
CatInterface
. This interface will allow us to make a cat meow.
interface CatInterface {
meow(message: string): void
}
const CatPlugin: {
new (): {
createInterface(ø: Record<string, unknown>): CatInterface
}
(ø: unknown): ø is CatInterface
}
Our CatPlugin
is very similar to our DogPlugin
. It is a simple JavaScript
object that contains a constructor function and a function for checking if an
object is an instance of the plugin’s interface.
Now that we have developed our CatPlugin
, we can use it to create an instance
of the CatInterface
.
const cat = new CatPlugin().createInterface()
cat.meow("meow!") // "meow!"
Now let’s develop a third plugin that depends on CatPlugin
. This plugin will provide
an interface for a panther.
interface PantherInterface {
panther: {
roar(): void
}
}
const PantherPlugin: {
new (): {
createInterface(ø: Record<string, unknown>): PantherInterface
}
getDependencies(): [typeof CatPlugin]
(ø: unknown): ø is PantherInterface
}
Our PantherPlugin
is similar to our other plugins, but it introduces a new
concept: plugin dependencies. The PantherPlugin
declares a dependency on the
CatPlugin
by returning an array containing the CatPlugin
from its
getDependencies
method.
This dependency will be used to ensure that the PantherPlugin
is only loaded
if the CatPlugin
is also loaded - otherwise, an error will be omitted if we register
PantherPlugin
without first having registered CatPlugin
.
Now that we have developed our PantherPlugin
, we can use it to create an
instance of the PantherInterface
.
const panther = new PantherPlugin().createInterface()
panther.panther.roar() // "roar!"
Defining an Engine
class
Now that we have developed some plugins, we need a way to load them into our
application. We will do this by defining an Engine
class.
declare class Engine<PX extends EnginePlugin[] = []> {
registerPlugin<P extends EnginePlugin>(
plugin: EnforceDependencies<this, P>,
): asserts this is Engine<[...PX, P]>
createInterface(): MergeInterfaces<this, "createInterface">
}
Our Engine
class is a generic class that takes a type parameter PX
which
represents the set of plugins that have been loaded into the engine. The
registerPlugin
method is used to register a new plugin with the engine. The
createInterface
method is used to create an instance of the engine’s
interface. This instance will be used to access the functionality provided by
the plugins.
Loading plugins into the Engine
Now that we have defined our Engine
class, we can use it to load our
plugins.
const engine = new Engine()
engine.registerPlugin(new DogPlugin())
engine.registerPlugin(new CatPlugin())
engine.registerPlugin(new PantherPlugin())
Accessing functionality from the Engine
Now that we have loaded our plugins into the Engine
, we can use the
Engine
to access the functionality that they provide.
const ø = engine.createInterface()
ø.bark()
ø.meow("hello")
ø.panther.roar()
Conclusion
In this article, we have seen how to use the TypeScript programming language to develop a well-typed plugin architecture. We have seen how to use TypeScript’s static type-checking to enforce dependencies between plugins, and how to generate type-safe APIs for communication between plugins.
Utility Types
In the definition of Engine
, we have used a number of utility types that are
defined in the following section.
ExtractPlugins
The ExtractPlugins
utility type is used to extract the set of plugins from
an Engine
instance.
type ExtractPlugins<T> = T extends Engine<infer PX> ? PX : never
UnionToIntersection
The UnionToIntersection
utility type is used to convert a union type to an
intersection type.
type UnionToIntersection<U> = (
U extends unknown ? (k: U) => void : never
) extends (k: infer I) => void
? I
: never
MergeInterfaces
The MergeInterfaces
utility type is used to merge the interfaces of a set of
plugins.
type MergeInterfaces<
E extends Engine,
K extends keyof EnginePlugin,
> = UnionToIntersection<ReturnType<Defined<ExtractPlugins<E>[number][K]>>>
Defined
The Defined
utility type is used to remove undefined
from a type.
type Defined<T> = T extends undefined ? never : T
GetDependencies
The GetDependencies
utility type is used to extract the dependencies of a
plugin.
type GetDependencies<P extends EnginePlugin> = Assume<
P extends EnginePlugin<unknown, infer D> ? D : never,
EnginePlugin[]
>
EnforceDependencies
The EnforceDependencies
utility type is used to ensure that a plugin’s
dependencies are satisfied.
type EnforceDependencies<
E extends Engine,
P extends EnginePlugin,
> = GetDependencies<P>[number] extends ExtractPlugins<E>[number]
? P
: PluginDependencyErrorMessage
PluginDependencyErrorMessage
The PluginDependencyErrorMessage
type is used to provide a helpful error
message when a plugin’s dependencies are not satisfied.
type PluginDependencyErrorMessage =
`Plugin is missing one or more dependencies.`