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.commandsapp.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)