Friday, November 9, 2018

A Simple TypeScript IOC Container

Motivation

When accessing external services from my web apps I like to use interfaces so I'm not tightly coupling a service implementation to where it's used. This has the benefit of making it easy to swap out the service implementation when writing unit tests. It's also very handy early on in the development process before you've implemented your service. You can create an implementation that just returns mock data and not get blocked waiting for the service.

To make this work you need a way to tell your application which implementation of the interface to use at run time. That's where an Inversion of Control (IOC) Container comes in. An IOC container allows you to use a technique known as dependency injection to get an instance of an interface without tightly coupling your code to a particular implementation.

If you've used Angular you know that it has dependency injection built in. But what if you're not using Angular? There are DI libraries out there for TypeScript, like InversifyJS. That's a pretty comprehensive library that even lets you use annotations to inject into constructors. This is great if you have deep objects with a lot of dependencies.

However, for most of my TypeScript apps all I need is a simple IOC container where I can ask it to give me an object for an interface. This is called a Dynamic Service Locator by Martin Fowler. So I created my own IOC container module for TypeScript based on this pattern.

Implementation

To start with we need a way to register interface implementations. For this we need a hash map to associate interface names to object constructors. Then we need a method to add them, which I will call bind(). Let's start by creating a module in its own file with a class called IocContainer.

class IocContainer
{
    private bindings = {};

    /** Binds an interface to an object
     * @param interfaceName Name of the interface
     * @param type Type to bind the interface to
     */
    public bind<T>(interfaceName: string, type: new () => T): IocContainer
    {
        this.bindings[interfaceName] = type;
        return this;
    }

The bind() method takes two parameters; the name of the interface and a constructor function of the class that implements that interface. Unfortunately there's no way to get the name of an interface from a generic definition in TypeScript so we must pass in the name of the interface as a string. All we do is add the mapping into the bindings object and we're done.

Next we need a way to get a concrete instance of an interface from the container. For this we need a get() method.

    /** Gets an instance for an interface as defined by bind() */
    public get<T>(interfaceName: string): T
    {
        let c = this.bindings[interfaceName];
        if (c)
        {
            return new c();
        }
        console.error(`There is no class bound to ${interfaceName}.`);
        return undefined;
    } 
}

The method takes one parameter; the name of the interface. We make it generic so it will automatically be cast to the correct type when we get it. All we do here is look up the constructor function for the interface name in the bindings object. Since it's a constructor function we can call "new" on it to create an instance of the object.

Note one limitation of this is that we can't pass parameters to the constructor. This is necessary because we don't know anything about the implementation! If the mapped object has its own dependencies on interfaces it too can use the container to get them.

The final thing we need to do is to export the container instance from our module. This makes the IOC container a singleton, which means there will be only one instance of it used everywhere.

var container = new IocContainer();
export default container;

Now for an example. Let's say we have an interface called ICustomerService and an implementation called MockCustomerService. Then we could register it like so.

import container from './services/IocContainer.js';
interface ICustomerService {
    getCustomer(id: number): Customer;
}
class MockCustomerService implements ICustomerService {
    getCustomer(id: number): Customer {
        return {...};
    }
}
container.bind("ICustomerService", MockCustomerService);

Finally, we get an instance of the interface from the container.

let service = container.get<ICustomerService>("ICustomerService");

Note that the get should be in a different file from where we did the binding. It would defeat the purpose to put the binding and get in the same file! Put the binding somewhere in your startup code. In this example, once we get our real customer service working all we need to do is change the binding and we're good to go.

Now let's say we wanted to write a unit test for the module that uses ICustomerService. All we would need to do is set up our binding differently in the unit test before testing the module.

class TestCustomerService implements ICustomerService {
    getCustomer(id: number): Customer {
        return {...};
    }
}
container.bind("ICustomerService", TestCustomerService);

Conclusion

Using an IOC container helps you avoid tight coupling to external services in your application by removing the construction of interface implementations from your code. This allows you to easily change the implementation later and provide different implementations in your unit tests.

Code hard!

No comments:

Post a Comment