Dependency Injection with TypeScript in Practice

The last letter in SOLID is a Dependencies Inversion Principle. It helps us decouple software modules so that it is easier to replace one module with another. The dependency injection pattern allows us to follow this principle.

In this post, I’ll show what dependency injection is, why it is useful, when to use it, and what instruments and tools can help frontend developers make the most of this template.

What a Dependency Is

In general, the concept of a dependency depends on a context, but for simplicity, we will call a dependency any module which is used by our module. When we start using a module in our code, this module becomes a dependency.

Analogy with Functions

Without going into academic definitions, we can compare dependencies with function arguments. Both are used in some way, both affect the functionality and operability of software that depends on them.

// Function `random` won't work without `min` and `max` arguments

function random(min, max) {
	if (typeof min === 'undefined' || typeof max === 'undefined') {
		throw new Error('All arguments are required');
	}

	return Math.random() * (max - min) + min;
}

In the example above the random function takes 2 arguments: min and max. If we don’t pass one of them, the function will throw an error. We can conclude that this function depends on those arguments.

However, this function depends not only on those 2 arguments but on the Math.random function as well. This is because if Math.random isn’t defined, the random function also won’t work—so Math.random is a dependency too.

We can make it clearer if we pass it as an argument into our function:

function random(a, b, randomSource) {
	if (
		typeof min === 'undefined' ||
		typeof max === 'undefined' ||
		typeof randomSource === 'undefined'
	) {
		throw new Error('All arguments are required');
	}

	return randomSource.random() * (max - min) + min;
}

Now it is clear that random function uses not only min and max but some random numbers generator as well. This kind of function will be called like that:

const randomBetweenTenAndTwenty = random(10, 20, Math);

…Or if we don’t want to manually pass Math as the last argument every time, we can use it as a default value in function arguments declaration:

function random(a, b, randomSource = Math) {
	// …Old code
}

// …And call this function like this:
const randomBetweenTenAndTwenty = random(10, 20);

This is Primitive Dependency Injection

Of course, it is not yet “canonical”, it is very primitive and it has to be done by hand, but the key idea is the same: we pass to our module everything it needs to be working.

Why You Need Dependency Injection

The code changes in the random function example may seem unnecessary. Indeed, why would we extract Math into an argument and use it like that? Why wouldn’t we just use it as it were, in the function body? There are 2 reasons for that.

Testability

When a module explicitly declares all the things it’s going to need, this module is much simpler to test. We see what needs to be prepared to run the test right away. We know what affects this module’s functionality and, if needed, can replace it with another implementation, maybe even a fake implementation.

Objects that look like a dependency but do something different are called mock objects. When running tests, they might keep track of how many times some function was called, how a module’s state changed, so that later we could check the results with expected.

They, in general, make it simpler to test a module, and sometimes they are the only way to test a module. This is the case with our random function—we cannot check the final result that this function should return since it is different every time this function is called. However, we can check how this function used its dependencies and derive the results from that.

// We can create a Mock-object
// that will always return 0.1 instead of a random number:
const fakeRandomSource = {
	random: () => 0.1
};

// Then, we will call our function,
// and give it this Mock-object as a dependency instead of Math:
const randomBetweenTenAndTwenty = random(10, 20, fakeRandomSource);

// Now, since the function's algorithm is determined and doesn't change,
// we can expect that a result will be always the same:
randomBetweenTenAndTwenty === 11; // true

Ability to Change Dependencies

Replacing a dependency while testing is only a special case. In general, we may want to replace one module with another for any reason. And if a new module behaves the same way as the previous, we can do that without any problems:

// If a new object contains a method `random`,
// we can use it as a dependency.
const otherRandomSource = {
	random() {
		// ...Another implementation of a random number generation.
	}
};

const randomNumber = random(10, 20, otherRandomSource);

It is very convenient when we want to keep our modules as separate from each other as we can.

However, is there a way to guarantee that a new module contains the random method? (It is crucial since we rely on this method later in the random function.) Apparently yes, there is. We can do it with interfaces.

Interfaces

An interface is a functionality contract. It constraints of a module’s behavior, what it must do, and what it must not. In our case, to guarantee random method existence we can use an interface.

Defining Behavior

To fixate that a module should have the random method that returns a number, we define an interface:

interface RandomSource {
	random(): number;
}

To fixate that a concrete object must have this method, we declare that this object implements this interface:

// Using a colon we declare
// that this object implements a `RandomSource` interface,
// hence must behave in a way described in this interface.
const otherRandomSource: RandomSource = {
	random = () => {
		// It must return a number,
		// otherwise TypeScript compiler will throw an error.
		return 42;
	}
};

Now we can declare that our random function takes as the last argument only an object that implements the RandomSource interface:

function random(a: number, b: number, source: RandomSource): number {
	if (typeof min === 'undefined' || typeof max === 'undefined' || typeof source === 'undefined') {
		throw new Error('All arguments are required');
	}

	return source.random() * (max - min) + min;
}

If we now try to pass an object which doesn’t implement the RandomSource interface, TypeScript compiler will throw an error.

const randomNumber1 = random(1, 10, Math);
// Alright, `Math` contains a `random` method, no errors.

const randomNumber2 = random(1, 10);
// Ok as well,
// `Math` is being used as a default argument value.

const randomNumber3 = random(1, 10, otherRandomSource);
// Also ok, since `otherRandomSource` implements
// the `RandomSource` interface.

const otherObject = {
  otherMethod() {};
};

const randomNumber4 = random(1, 10, otherObject);
// Error!
// `otherObject` does not implement the required interface.

Depending on Abstraction

At a first glance, this might seem like overkill. However, this helps us achieve many perks.

  • We drastically decrease modules coupling this way.
  • We have to design our systems before we start coding.

When we design a system beforehand we tend to use abstract contracts. Using those contracts we design our own modules and adapters for 3-party code. This unlocks the ability to interchanges modules with others without changing the whole system but only a changing part.

Especially it becomes handy when modules are more complex than those in the examples above. For instance, when a module has an internal state.

Stateful Modules

In TypeScript, there are many ways to create a stateful object, such as using closures or classes. In this post, we will use classes.

As an example, we will take a counter. As a class, it would be written something like this:

class Counter {
	private state: number = 0;

	public increase = (): void => {
		this.state++;
	};

	public decrease = (): void => {
		this.state--;
	};

	get stateOf(): number {
		return this.state;
	}
}

Its methods give us a way to change its internal state:

const counter = new Counter();
counter.stateOf; // 0

counter.increase();
counter.stateOf; // 1

counter.decrease();
counter.stateOf; // 0

It’s getting tough when some objects like this one depend on others. Let’s assume that this counter should not only keep and change its internal state but also log it into a console every time it changes.

class Counter {
	private state: number = 0;

	// Adding a method for logging.
	private log = (): void => {
		console.log(this.state);
	};

	public increase = (): void => {
		// And now when the state changes...
		this.state++;
		this.log();
	};

	public decrease = (): void => {
		// ...it will be logged out into a console.
		this.state--;
		this.log();
	};

	get stateOf(): number {
		return this.state;
	}
}

There we see the same problem as we saw at the beginning of this post. Counter uses not only its state but also another module—console. Ideally, it should also be explicit, or in other words, injected.

Dependency Injection in Classes

We can inject a dependency in a class using a setter or a constructor. We will do the latter.

A constructor is a special method that gets called when an object is being created. You would usually specify all the actions to perform at the object initialization.

For example, if we want to log a greeting into a console when an object is created we can use this code:

class Counter {
	constructor() {
		console.log('Hello world!');
	}

	// ...Other code.
}

const counter = new Counter();
// "Hello world!"

Using a constructor we can also inject all the required dependencies.

Simple Injection

We want to “teach” a class to work with dependencies the same way as functions from examples before.

So, our class Counter uses the method log of a console object. That means that this class expects as a dependency an object that has a method log. It doesn’t matter whether it will be the console object or something else, the only condition here is for the object to have a log method.

When we want to fixate the behavior we use interfaces, so the Counter’s constructor should take as an argument an object that implements an interface with a method log:

interface Logger {
	log(message: string): void;
}

Then we use this interface to declare the “behavior requirement” in the code:

class Counter {
	// This private field will keep a reference
	// to the logger object...
	private logger: Logger;

	constructor(logger: Logger) {
		// ...that we will set at the initialisation.
		this.logger = logger;
	}

	// ...Other code.
}

// Or if using field auto-assign:
class Counter {
	// When written this way, the argument in the constructor
	// will be automatically assigned to the `logger` private field.
	constructor(private logger: Logger) {}

	// ...Other code.
}

To initialize a class instance we would use this code:

const counter = new Counter(console);

…And if we would want to, let’s say, alert instead of logging into a console, we would change the dependency object this way:

// It is enough to make sure that dependency-object
// has all the required methods,
// or implements the required interface.
const customLogger: Logger = {
	log(message: string): void {
		alert(message);
	}
};

const counter = new Counter(customLogger);

Automatic Injection and DI-Containers

Right now our Counter class doesn’t use any implicit dependencies. That’s good, however, this injection is not convenient.

  • We have to manually inject every dependency,
  • We have to keep the dependency order when injecting.

In reality, we would want to automate it. There is a way to do that, and it’s called a DI-container.

On the whole, a DI-container is a module that does only one thing—it provides dependencies to every other module in a system. Container knows exactly which dependencies a module needs, and injects them when needed. Thus we free other modules of figuring out this stuff, and the control goes to a special place. This is the behavior that is described in SRP and DIP principles of SOLID.

In practice for this to work, we need another layer of abstraction—interfaces. (Thus TypeScript, JavaScript doesn’t have those.) Interfaces here are a link between different modules.

A container knows what kind of behavior a module needs, knows which modules implement it, and when creating an object it will provide access to them automatically.

In pseudocode it would look like this:

// P S E U D O C O D E:

// Hey, container!
// When you're asked of an object that implements `SomeInterface`
// you should give access to an instance of `SomeClass`.
container.register(SomeInterface, SomeClass);

Despite the fact that this code is not real, it is not so far from reality.

Automatic Injection Tools

There is a great tool for TypeScript, that does exactly the thing we described above. It uses generic-functions to bind an interface and implementation. The code uses this tool would look like this:

import { DIContainer } from '@wessberg/di';

// Creating a container...
const container = new DIContainer();

// ...An interface:
interface Logger {
	log(message: string): void;
}

// ...And implementation:
export class ConsoleLogger implements Logger {
	public log = (message: LogEntry): void => console.log(message);
}

// An then, declare that
// when someone asks a container of an object
// that implements the `Logger` interface,
// it should return an instance of a `ConsoleLogger` class.
container.registerSingleton<Logger, ConsoleLogger>();

// This <Logger, ConsoleLogger> syntax is a generic function.
// It uses type-parameters to bind the `Logger` type with `ConsoleLogger` type.

// The `Logger` is an abstract interface, and the `ConsoleLogger` is a more concrete class.
// Since TypeScript treats both as types, we can use them as type-parameters in a generic function.

Now, if we want to access a dependency in our Counter class, we can do that by writing this:

// ...Old code.

class Counter {
	constructor(private logger: Logger) {}

	private log = (): void => {
		this.logger.log(this.state);
	};

	// ...Other `Counter`'s code.
}

container.registerSingleton<Counter>();

The last line registers the Counter class in the container itself. That way the container will know that Counter can ask for dependencies from it.

Point of Using Containers

First of all, we can now change the implementation of the whole project by changing only one line.

For example, if we want to change the logger implementation in every place that uses it, it is enough to change only the module registration:

// New implementation...
class CustomLogger implements Logger {
	public log = (message: LogEntry): void => alert(message);
}

// ...which should replace the old `ConsoleLogger`.
// We change only the registration, one line below:
container.registerSingleton<Logger, CustomLogger>();

Also, we don’t pass the dependencies by hand, we don’t have to keep the order of dependencies anymore, so modules become less coupled.

This container’s killer-feature is that it doesn’t use decorators (on the contrary for let’s say Inversify.js). Type-parameters registration makes it easier to distinguish infrastructure code from the production code. Which is a technic for building robust applications advised in Patterns for Fault Tolerant Software by Robert S. Hanmer.

What the Heck is registerSingleton?

singleton and transient here are lifetime types of an object.

registerSingleton creates a single object once, which later will be passed in every place that requires it. And registerTransient creates a new object every time.

Transient-objects are used for dealing with some unique entities like network requests—objects that should be created from scratch every time. Singleton-objects are used when we can use the same instance, for example, for logging.

Close-to-Real-Life Example

I wrote a small application, which alerts a unique ID of a click, its time, and a position on the screen when a user clicked. Also, it logs into a console “Hello world” every 5 seconds.

It is quite dumb, but that’s not the point :–)I want to show using it how we can use DI with TypeScript on the frontend.

(There is also the source code.)

Tools

We will use @wessberg/di as a container and @wessberg/di-compiler to make the magic happen at compile time. We don’t want to overload the client code with lots of infrastructure code. This toolchain is the best I tried, it doesn’t increase the build bundle much and is very convenient to use.

Entry Point

the entry point’s code is:

export class AppInitiator {
	constructor(
		private dateTimeSource: DateTimeSource,
		private idGenerator: UuidGenerator,
		private clickHandler: EventHandler<MouseEvent>,
		private logger: Logger,
		private timer: Timer,
		private env: Window
	) {}

	private greet = (): void => this.logger.log('Hello world!');
	private setupTimer = (): void => this.timer.invokeEvery(this.greet, 5000);
	private registerClicks = (): void => this.clickHandler.on('click', this.handleClick);

	private handleClick = (e: MouseEvent): void => {
		const position = [e.pageX, e.pageY];
		const datetime = this.dateTimeSource.toString();
		const eventId = this.idGenerator.generate();
		this.env.alert(`${eventId}, ${datetime}: Mouse was clicked at ${position} `);
	};

	public init = (): void => {
		this.setupTimer();
		this.registerClicks();
	};
}

container.registerSingleton<AppInitiator>();

All the interesting things are in the class constructor. There we ask a container for all the dependencies.

First-Level Dependencies

Those are dependencies that the main module depends on:

  • DateTimeSource
  • UuidGenerator
  • EventHandler<MouseEvent>
  • Logger
  • Timer

DateTimeSource

To get access to date and time, we use BrowserDateTimeSource which is registered as the implementation for DateTimeSource. Notice that when we ask for this dependency we use an interface because that’s the key point—everything should depend on an abstraction.

export class BrowserDateTimeSource implements DateTimeSource {
	get source() {
		return new Date();
	}

	public toString = (): UtcDateTimeString => this.source.toUTCString();
	public valueOf = (): TimeStamp => this.source.getTime();
}

container.registerSingleton<DateTimeSource, BrowserDateTimeSource>();

UuidGenerator

A unique ID generator is an adapter for nanoid. Notice that we refer to this 3-party module only once, when registering the adapter. This is handy if we decide to replace nanoid with another UUID generator.

export class IdGenerator implements UuidGenerator {
	constructor(private adaptee: ThirdPartyGenerator) {}
	generate = () => this.adaptee();
}

container.registerSingleton<ThirdPartyGenerator>(() => nanoid);
container.registerSingleton<UuidGenerator, IdGenerator>();

EventHandler<MouseEvent>

An event handler uses a generic-interface EventHandler<MouseEvent>. It is important to request exactly this dependency from a container later. If we pass another type-parameter in this interface the container will search for a module that is registered with that parameter. It is convenient when we work with similar object types.

export class ClickHandler implements EventHandler<MouseEvent> {
	constructor(private env: Window) {}

	public on = (event: EventKind, callback: EventCallback<MouseEvent>): void =>
		this.env.addEventListener(event, callback);

	public off = (event: EventKind, callback: EventCallback<MouseEvent>): void =>
		this.env.removeEventListener(event, callback);
}

container.registerSingleton<EventHandler<MouseEvent>, ClickHandler>();

Logger

This one we have already seen :–)

export class ConsoleLogger implements Logger {
	public log = (message: LogEntry): void => console.log(message);
}

container.registerSingleton<Logger, ConsoleLogger>();

Second-Level Dependencies

Those are dependencies of dependencies, as, for instance, env in the ClickHandler class or adaptee in the IdGenerator.

It doesn’t matter for a container what level a dependency of. A container provides all the dependencies with no problems. (Unless there are cyclic dependencies, but that’s a topic for another post :–)

// For `IdGenerator` we register dependencies like:
container.registerSingleton<ThirdPartyGenerator>(() => nanoid);

// And for the `ClickHandler` (is requires `Window`):
container.registerSingleton<Window>(() => window);

Downsides

The main issue with DI-containers is that when you’re using it you have to register all the dependencies there. It sometimes is not as flexible as we might want.

Another downside is that access to the entry point becomes available only from the container and it might seem a bit dirty. (Although, for the entry point it is acceptable.)

const service = container.get<AppInitiator>();
service.init();

Whether to Use

I’ve written a couple of “canonical OOP-style” projects, and all in all, it’s fine. It certainly bloats the infrastructure a bit, but in bigger apps the convenience outweighs it. Especially when even the frontend has a tool that doesn’t overly increase the amount of code that goes to the client.

Sources

OOP and Classes

Dependency Injection

Tools

Books