A Lazy Sequence

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

21 April 2018