2024-10-14
A recent question in the TypeScript Discord needs more space than is available there. It happens to be a pretty good question for providing a walkthrough for how I create complex systems, so I am roughly reproducing it here to be able to reference it in the future.
The question, stripped down from the back and forth to arrive here:
How can I create a type safe plugin system which requires that plugin dependencies are loaded? I'm looking for an API similar to the following:
; ;
In addition to this, when adding plugins to the app, there should be a type error if any dependencies are not met.
ext1; // ok, ext1 has no dependencies ext1ext2; // ok, ext2 depends on ext1, which is loaded ext2; // error, ext2 requires that ext1 has been loaded
The asker also provided a playground link with their attempt, but I've decided to build this from the ground up rather than work from it.
The proposed API is fairly complicated so we'll start with a simplified version. (just looking at the provided code, it definitely seems overly complicated, but good questions are usually heavily simplified, so I generally assume the additional complexity is important for reasons outside of the scope of the question)
Let's start with the following version:
;
;
;
ext1ext2; // ok, dependencies met
ext2; // compile error
This version is missing quite a few features of the original:
app
parameter passing for properties of app.commands
app.commands
(command names)This is a good thing, as it lets us focus on figuring out a good base from which the additional features can gradually be added on. When developing the types, being able to actually run the code isn't important, so we'll start with interfaces everywhere and replace them with classes at the end.
To start, we'll define the Plugin
and Application
interfaces:
There are a couple things to note about these types:
Plugin
doesn't try to define specific types, it is just used as a constraint.Application
has a single type parameter which describes all dynamic content.The definition for PluginCommands
will change in the future, but for now let's
just get the intersection of the commands
property of all plugins, which can
be done with a mapped
type and a
union to intersection
helper
;
;
With this, everything nearly works! In the playground we
only have one undesirable compiler error due to the Plugins
type parameter being
invariant.
The current variance of the Application
class means that Application<{ Ext1: typeof ext1 }>
is not assignable to Application<{}>
(undesirable) and that
Application<{}>
is not assignable to Application<{ Ext1: typeof ext1 }>
(desirable). This is a problem since it means that if we have two plugins
without dependencies, attempting to add them both to an application will be a
compiler error. (playground with variance issue)
;
;
;
ext0; // ok
ext1; // ok
ext0ext1; // error
Thankfully, we can fix this by updating the commands
property to exclude the
app
parameter. (playground with fixed variance issue)
;
;
Next, let's look at getting inferred parameter types working. With lots of
commands, it's annoying to have to repeat the type of the app
parameter for
each command.
In TypeScript 4.9
and later, we can achieve this with the satisfies
operator without the need for a function call.
;
;
The duplication of the Ext1
name when defining the constraint for ext2
is
unfortunate, but easily fixed with a helper to apply multiple dependent plugins.
We can add a default to Plugin
to also make plugins which don't require
dependencies simpler to define: (playground with satisfies)
;
;
// If both ext1 and ext2 were needed, could use PluginWithDeps<typeof ext1 | typeof ext2>
Now that we have a working system with simple types, we can start extending it to support the more complex version in the original question. Let's start with the factory function for plugins.
With this change, it probably makes sense to update PluginWithDeps
to operate
on factory functions too. (playground with factory)
Personally, this is as far as I would take this system. Without a good reason for it, I might even stop earlier, before adding the factory function for plugins... but for completeness, let's extend this to meet the original API requested.
Let's start with the commands, they aren't just functions in the original API, but also have a name.
This is a different shape than is configured when creating commands, so we need
to introduce different types for the configured commands and the runtime
commands. The ExtensionLoader
is responsible for making the transformation
from the configure shape to the runtime shape.
To make this work I just threw code at the wall until TS stopped complaining... Without knowing the why for this additional complexity, it's hard to tell what exactly should be required. (playground with commands)