A minimal TypeScript dependency injector
Dependency injection is a handy tool for building systems. For small projects I just want a small simple implementation of the ideas. This post is a quick look at the implementation I cooked up with TypeScript that covers everything I need for a number of the small systems I’ve been writing recently.
Aside from the decoupling system components, I wanted this to make it trivial to ensure that components start up in the right order, and I wanted it to leverage the type checker and inferencer to ensure that the given system is complete. There’s no support for configuration files, or more complex component life cycles.
Here’s the whole thing:
export type Injector<T> =
<K extends keyof T = keyof T>(key: K) => Promise<T[K]>
export type Injectable<TComp, TDeps = {}> = (injector: Injector<TDeps>) => Promise<TComp>
export type SystemDescription<T> = {
[P in keyof T]: Injectable<T[P], T>
};
type Pending<T> = {
[P in keyof T]: Promise<T[P]>
};
export function startSystem<T, K extends keyof T>(s: SystemDescription<T>, primary: K):
Promise<T[K]> {
const pending:Partial<Pending<T>> = {};
function getPending<K extends keyof T = keyof T>(key: K): Promise< ;T[K]> {
if (!pending.hasOwnProperty(key)) {
pending[key] = s[key] (getPending);
}
const p = pending[key];
return p!;
}
return getPending(primary);
}
The description of all the types is a little tangled, but actually its pretty simple once you boil it all down. The first bit to look at is the SystemDescription
. This is a mapping of names to Injectable
functions. At some point this SystemDescription
will be passed to startSystem
and it will fire everything up. startSystem
will return the component named by the paramater primary
.
Injectable
functions simply take an Injector
function, and return a Promise
for a component. The Injector
is simply a function that when given a key, will return a Promise
for the matching component. Because of the way the types are described, the full set of keys is computed from the injectable dependencies (TDeps
), and the keys of the SystemDescription
.
The real mechanism that makes this all work is promises. Injectables
are typically async functions, and they await calls to the injector they are provided. Once all those components become available, the Injectable function is able to construct the component that was waiting for those dependencies and return it in a promise. The downside to this is that if you have a cycle in your system map where say A depends on B, B depends on C and C depends on A, the system will never start up, and you wont get any warnings. So don’t do that I guess.
startSystem
itself is pretty straight forward once you understand the basic notion of how Injectable
and Injector
work. pending
is just a cache of all the promises created so far. The function getPending
is action our concreteInjector
. In order to kick everything off, getPending
is simply called to request the primary component, and return it.
As an example, imagine a program that has ILogger
, IConfig
, and IWebApp
interfaces, and you wanted to stitch these together. Heres a hypthothetical example:
function consoleLogging(): Injectable<ILogging> {
return async () => new ConsoleLogger();
}
function staticConfig(configPath: string): Injectable<IConfig> {
return async () => new JsonFileConfig(configPath);
}
function webApp(): Injectable<IWebServer, {config: IConfig, logging: ILogging}> {
return async (injector) => {
const config = await injector("config");
const logging = await injector("logging");
return new WebApp(config, logging);
};
}
const systemDescription = {
"config": staticConfig("defaults.json"),
"logging": consoleLogging(),
"web": webApp()
};
async function main() {
const server = await startSystem(systemDescription, "web");
server.listen();
}
This is obviously a very minimal example. In a real program, the Injectables may make decisions based on configuration or parameters. You may have define your system description as a type alias, and then populate the object programmatically. And I find it is pretty common to just have different system descriptions for different usecaes (running a tool as a web server, vs as a command line app, vs in testcases).