loading...

Typescript Dependency Injection in 200 LOC

darcyrayner profile image Darcy Rayner ・8 min read

One of the most common patterns in object oriented programming is dependency injection, and the inversion of control principle, (IOC). IOC containers are often feature packed, complex beasts that can stump even seasoned programmers. They take a collection of types with dependencies and when you need an instance of something they can automagically wire one up for you.

You might have seen Typescript containers in frameworks like Angular, and NestJs with their module systems. Or maybe you are using a stand alone container like Inversify.

One of the best ways to demystify programming concepts is to go out and build it yourself, so this article will build a minimal toy container step by step. But first…

A quick history lesson

Back yonder during the framework wars of 2014, some Google engineers had run into a problem. They had been working on Angular 2 when they realised the language they were building it in, Typescript, had a fatal flaw. It was kind of a deal breaker, so they did what Google engineers do in these kinds of situations. They invented a new language. It was called AtScript.

Did I ever tell you the tragedy of AtScript the Short Lived

I'm not here to rehash the history of AtScript. Anders Hejlsberg,(creator of Typescript), gives his short version of it here. Like Anders mentions in his talk, Typescript at the time was missing two crucial features which AtScript was meant to address; Decorators and Reflection. And they were the secret sauce that made makes IOC in Typescript possible.

Decorators

If you've used a Typescript container before, you've probably seen something like this:

@Injectable()
class SomeService {
    constructor(private anotherService: AnotherService) {}
}

At the top there we have the Injectable decorator. The decorator is saying this class can have its dependencies automatically injected.

A decorator is a function which wraps a class, function or method and adds behaviour to it. This is useful for defining metadata associated with an object. It also ties into the way reflection works in Typescript.

Reflection

In order to know which things to wire up, we need to be able to inspect types at runtime. Let's look at how Javascript does things before getting to Typescript.

const a = "hello there";
const b = 0b1;
console.log(typeof a); // "string";
console.log(typeof b); // "number";

While it isn't perfect, Javascript does support a degree of basic runtime reflection. Besides the primitive types of the language, (num, boolean, object, string, array etc), classes also carry runtime information:

class Alpha {}
const a = new Alpha();
a instanceof Alpha; // true

We can also inspect the class's prototype to get a list of methods. But that's where we start to hit some limits. There is no easy way to extract the names of class properties or method parameters. Traditional pure javascript containers would use hacks like casting the function or class to a string and manually parsing that string to get the names of each parameter/property. That name would then be used by the container to lookup the correct dependency. Of course, this would fail if you ran a minifier over your code, because all those parameter names would change. This was a common issue with Angular 1, and the work arounds involved a lot of redundancy.

So, vanilla Javascript doesn't help us much in the reflection department. To combat this, Typescript uses a library called reflect-metadata to store additional type information. For instance, Typescript types assigned to parameters and properties are made available at runtime. It is enabled with the 'emitDecoratorMetadata' compiler option.

@SomeDecorator()
function someFunc(a: number, b: string){}
Reflect.getMetadata('design:types', someFunc); // Number, String

There are two catches though:

  1. Classes/functions must have a decorator for them to save metadata.
  2. Only classes/enums/primitive types can be recorded. Interfaces and union types come through as 'Object'. That's because these types disappear entirely after compilation, whereas classes hang around.

Anyway, that's enough background for now. If Typescript decorators/reflect-metadata are still confusing you, go check out the official tutorial.

The Code

Our container is going to use two main concepts. Tokens and Providers. Tokens are an identifier for something that our container needs to know how to create, and providers describe how to create them. With that in mind, a minimal public interface for the Container class looks like this.

export class Container {
    addProvider<T>(provider: Provider<T>) {} // TODO
    inject<T>(type: Token<T>): T {} // TODO
}

Now let's define our Token. Tokens can either refer to a class or, in cases where the parameter type doesn't give enough context about what to inject, a constant attached to a parameter with a decorator.

const API_URL_TOKEN = new InjectionToken('some-identifier');
const TWITTER_TOKEN = new InjectionToken('another-identifier');
class SomeClass {
    // Both AService, API_URL_TOKEN, and TWITTER_URL_TOKEN are all tokens.
    // We will define the Inject decorator later.    
    constructor(b: AService, @Inject(API_URL_TOKEN) apiURL: string, @Inject(TWITTER_URL_TOKEN) twitterUrl: string) {}
}

Our definition for Tokens looks like this:

// We use this to refer to classes.
export interface Type<T> extends Function {
    // Has a constructor which takes any number of arguments. 
    // Can be an implicit constructor.   
    new (...args: any[]): T; 
}

export class InjectionToken {
    constructor(public injectionIdentifier: string) {}
}

// Our combined Token type
Token<T> = Type<T> | InjectionToken;

Next, let's define the Providers. There are three different Provider types we will implement. One for providing an existing value as a singleton, one for providing via a factory function, and one for providing just the class name to use.

// Every provider maps to a token.
export interface BaseProvider<T> {
    provide: Token<T>;
}

export interface ClassProvider<T> extends BaseProvider<T> {
    useClass: Type<T>;
}

export interface ValueProvider<T> extends BaseProvider<T> {
    useValue: T;
}

// To keep things simple, a factory is just a function which creates the type.
export type Factory<T> = () => T;

export interface FactoryProvider<T> extends BaseProvider<T> {
    useFactory: Factory<T>;
}

export type Provider<T> = ClassProvider<T> | ValueProvider<T> | FactoryProvider<T>;

For convenience let's throw in some type guards as well.

export function isClassProvider<T>(provider: BaseProvider<T>): provider is ClassProvider<T> {
    return (provider as any).useClass !== undefined;
}
export function isValueProvider<T>(provider: BaseProvider<T>): provider is ValueProvider<T> {
    return (provider as any).useValue !== undefined;
}
export function isFactoryProvider<T>(provider: BaseProvider<T>): provider is FactoryProvider<T> {
    return (provider as any).useFactory !== undefined;
}

This is pretty good for our base API. We just need to define two decorators before we are ready to implement the container.

// This class decorator adds a boolean property to the class
// metadata, marking it as 'injectable'. 
// It uses the reflect-metadata API.
const INJECTABLE_METADATA_KEY = Symbol('INJECTABLE_KEY');
export function Injectable() {
    return function(target: any) {
        // target in this case is the class being decorated.    
        Reflect.defineMetadata(INJECTABLE_METADATA_KEY, true, target);
        return target;
    };
}
// We also provide an easy way to query whether a class is
// injectable. Our container will reject classes which aren't
// marked as injectable.
export function isInjectable<T>(target: Type<T>) {
    return Reflect.getMetadata(INJECTABLE_METADATA_KEY, target) === true;
}

And we define the Inject decorator, which maps a parameter to another Token.

const INJECT_METADATA_KEY = Symbol('INJECT_KEY');
// This is a parameter decorator, it takes a token to map the parameter to.
export function Inject(token: Token<any>) {
    return function(target: any, _: string | symbol, index: number) {
        Reflect.defineMetadata(INJECT_METADATA_KEY, token, target, `index-${index}`);
        return target;
    };
}
export function getInjectionToken(target: any, index: number) {
    return Reflect.getMetadata(INJECT_METADATA_KEY, target, `index-${index}`) as Token<any> | undefined;
}

The Container

The implementation for adding providers is fairly simple. You can see it is just a simple key value store. The providers map uses any types, but we know the Token and Provider will always match because the only way to insert into that map is with the addProvider method.

class Container {
    private providers = new Map<Token<any>, Provider<any>>();

    addProvider<T>(provider: Provider<T>) {
        this.assertInjectableIfClassProvider(provider);
        this.providers.set(provider.provide, provider);
    }
    // ...
}

We use the assertInjectableIfClassProvider method to make sure all the classes which are provided to the container have been marked as Injectable, and therefore have metadata. This isn't strictly necessary, but it will help us catch issues at configuration time.

class Container {
    // ...
    private assertInjectableIfClassProvider<T>(provider: Provider<T>) {
        if (isClassProvider(provider) && !isInjectable(provider.useClass)) {
            throw new Error(
            `Cannot provide ${this.getTokenName(provider.provide)} using class ${this.getTokenName(
                provider.useClass
            )}, ${this.getTokenName(provider.useClass)} isn't injectable`
            );
        }
    }

    // Returns a printable name for the token.
    private getTokenName<T>(token: Token<T>) {
        return token instanceof InjectionToken ? token.injectionIdentifier : token.name;
    }
    // ...
}

Next we have our injection function. This first method looks up the provider, and the second method determines which type of provider it is, then handles each case separately.

class Container {
    // ...
    inject<T>(type: Token<T>): T {
        let provider = this.providers.get(type);
        return this.injectWithProvider(type, provider);
    }

    private injectWithProvider<T>(type: Token<T>, provider?: Provider<T>): T {
        if (provider === undefined) {
            throw new Error(`No provider for type ${this.getTokenName(type)}`);
        }
        if (isClassProvider(provider)) {
            return this.injectClass(provider as ClassProvider<T>);
        } else if (isValueProvider(provider)) {
            return this.injectValue(provider as ValueProvider<T>);
        } else {
            // Factory provider by process of elimination
            return this.injectFactory(provider as FactoryProvider<T>);
        }
    }
    // ...
}

The value and factory providers are pretty straight forward. One is a method call, one just returns a value. The class provider is a little more complex, it needs to construct the items in the parameter list for the constructor, and then invokes the constructor using the class reference.

class Container {
    // ...
    private injectValue<T>(valueProvider: ValueProvider<T>): T {
        return valueProvider.useValue;
    }

    private injectFactory<T>(valueProvider: FactoryProvider<T>): T {
        return valueProvider.useFactory();
    }

    private injectClass<T>(classProvider: ClassProvider<T>): T {
        const target = classProvider.useClass;
        const params = this.getInjectedParams(target);
        return Reflect.construct(target, params);
    }
    // ...
}

The implementation for building the parameter list is where things get tricky. We invoke the reflect-metadata API in order to get a list of types for each parameter of the constructor. For each of those parameters, we find the relevant token, and then construct is recursively.

public class Container {
    // ...
    private getInjectedParams<T>(target: Type<T>) {
        const argTypes = Reflect.getMetadata(REFLECT_PARAMS, target) as (InjectableParam | undefined)[];
        if (argTypes === undefined) {
            return [];
        }
        return argTypes.map((argType, index) => {
            // The reflect-metadata API fails on circular dependencies,
            // and will return undefined for the argument instead.
            // We could handle this better, but for now let's just throw an error.
            if (argType === undefined) {
                throw new Error(
                    `Injection error. Recursive dependency detected in constructor for type ${
                    target.name
                    } with parameter at index ${index}`
                );
            }
            // Check if a 'Inject(INJECTION_TOKEN)' was added to the parameter.
            // This always takes priority over the parameter type.
            const overrideToken = getInjectionToken(target, index);
            const actualToken = overrideToken === undefined ? argType : overrideToken;
            let provider = this.providers.get(actualToken);
            return this.injectWithProvider(actualToken, provider);
        });
    }
}

Using it

That's it for the implementation. Here's what it looks like using our new container.


const API_TOKEN = new InjectionToken('api-token');

@Injectable()
class SomeService {
    constructor(@Inject(API_TOKEN)) {}
}

@Injectable()
class InjectableClass {
    constructor(public someService: SomeService) {}
}

const container = new Container();

container.addProvider({ provide: API_TOKEN, useValue: 'https://some-url.com' });
container.addProvider({ provide: SomeService, useClass: SomeService });
container.addProvider({ provide: InjectableClass, useClass: InjectableClass });

const instance = container.inject(InjectableClass);

Conclusion

While the toy container we built here was fairly simple, it's also powerful. You can already see the bones of how other more advanced containers are built. A working demo repository with tests and documentation can be found here. If you are up for a challenge, fork it and see if you can extend it with the following features:

  • Early detection of circular references, (when you add your providers).
  • Nested containers, add the ability to provide types from child containers, (similar to Angular/NestJs modules).
  • Factories with injected parameters.
  • Specifiy scope of instance lifecycle in providers, (eg. singleton).

Posted on by:

darcyrayner profile

Darcy Rayner

@darcyrayner

Aussie in the Big Apple. Compulsive tea drinker. Lead Engineer at Two Bulls

Discussion

markdown guide
 

I've found the reflect-metadata library a bit heavy to ship with apps so you might be interested in the @abraham/reflection alternative I wrote. I actually just released v0.5.0.

 

Nice, I might have to give that a try on my next Angular project.

 

I love typescript but never take advantage of the more advanced features in my codebases (nodejs, react) because of the rejection I get from most teams when trying to introduce it into our code base.

Great article, keep up the awesome work.

 

I think it's fair that teams should try to fight against too much complexity in their codebase, but sometimes a slightly more complex idea like Inversion of Control can dramatically simplify everything else. It's a tradeoff, and there is not always an easy answer about the best way to do things. Just keep making the case to try new ideas.

 

See also this much more minimalist approach - 10 lines of code and no dependencies:

dev.to/mindplay/minimal-di-contain...

I only wish I could figure out how to type-hint it correctly. (I don't even know that it's possible - short of manually typing out the interfaces, but it really ought to be possible with inference...)