DEV Community

Cover image for Dependency Injection in TypeScript
Vladimir Lewandowski
Vladimir Lewandowski

Posted on

Dependency Injection in TypeScript

The D letter in SOLID is the Dependency Inversion principle. It helps to decouple modules from each other so that you can easily swap one part of the code for another.

One of the techniques that helps to follow this principle is Dependency Injection.

This post was inspired by Sasha Bespoyasov's article and is partially a translation of it.

What Are Dependencies?

For ease of reference, we will define a dependency as any module that is used by our module.

Let's look at a function that takes two numbers and returns a random number in a range:

const getRandomInRange = (min: number, max: number): number =>
  Math.random() * (max - min) + min;
Enter fullscreen mode Exit fullscreen mode

The function depends on two arguments: min and max.

But you can see that the function depends not only on the arguments, but also on the Math.random function. If Math.random is not defined, our getRandomInRange function won't work either. That is, getRandomInRange depends on the functionality of another module. Therefore Math.random is also a dependency.

Let's explicitly pass the dependency through arguments:

const getRandomInRange = (
  min: number,
  max: number,
  random: () => number,
): number => random() * (max - min) + min;
Enter fullscreen mode Exit fullscreen mode

Now the function uses not only two numbers, but also a random function that returns number. We will call the getRandomInRange like this:

const result = getRandomInRange(1, 10, Math.random);
Enter fullscreen mode Exit fullscreen mode

To avoid passing Math.random all the time, we can make it the default value for the last argument.

const getRandomInRange = (
  min: number,
  max: number,
  random: () => number = Math.random
): number => random() * (max - min) + min;
Enter fullscreen mode Exit fullscreen mode

This is the primitive implementation of the Dependency Inversion. We pass to our module all the dependencies it needs to work.

Why Is It Needed?

Really, why put Math.random in the argument and use it from there? What's wrong with just using it inside a function? There are two reasons for that.

Testability

When all dependencies are explicitly declared, the module is easier to test. We can see what needs to be prepared to run a test. We know which parts affect the operation of this module, so we can replace them with some simple implementation or mock implementation if needed.

Mock implementations make testing a lot easier, and sometimes you can't test anything at all without them. As in the case of our function getRandomInRange, we cannot test the final result it returns, because it is.... random.

/*
 * We can create a mock function
 * that will always return 0.1 instead of a random number:
 */
const mockRandom = () => 0.1;

/* Next, we call our function by passing the mock object as its last argument: */
const result = getRandomInRange(1, 10, mockRandom);

/*
 * Now, since the algorithm within the function is known and deterministic,
 * the result will always be the same:
 */
console.log(result === 1); // -> true
Enter fullscreen mode Exit fullscreen mode

Replacing an Dependency With Another Dependency

Swapping dependencies during tests is a special case. In general, we may want to swap one module for another for some other reason.

If the new module behaves the same as the old one, we can replace one with the other:

const otherRandom = (): number => {
  /* Another implementation of getting a random number... */
};

const result = getRandomInRange(1, 10, otherRandom);
Enter fullscreen mode Exit fullscreen mode

But can we guarantee that the new module will behave the same way as the old one? Yes we can, because we use the () => number argument type. This is why we use TypeScript, not JavaScript. Types and interfaces are links between modules.

Dependencies on Abstractions

At first glance, this may seem like overcomplication. But in fact, with this approach:

  • modules become less dependent on each other;
  • we are forced to design the behavior before we start writing the code.

When we design behavior in advance, we use abstract conventions. Under these conventions, we design our own modules or adapters for third-party ones. This allows us to replace parts of the system without having to rewrite it completely.

This especially comes in handy when the modules are more complex than in the examples above.

Dependency Injection

Let's write yet another counter. Our counter can increase and decrease. And it also logs the state of.

class Counter {
  public state: number = 0;

  public increase(): void {
    this.state += 1;
    console.log(`State increased. Current state is ${this.state}.`);
  }

  public decrease(): void {
    this.state -= 1;
    console.log(`State decreased. Current state is ${this.state}.`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we run into the same problem as before — we use not only the internal state of the class instance, but also another module — console. We have to inject this dependence.

If in functions we passed dependencies through arguments, then in classes we will inject dependencies through the constructor.

Our Counter class uses the log method of the console object. This means that the class needs to pass some object with the log method as a dependency. It doesn't have to be console — we keep in mind the testability and replaceability of modules.

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

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

  public state: number = 0;

  public increase(): void {
    this.state += 1;
    this.logger.log(`State increased. Current state is ${this.state}.`);
  }

  public decrease(): void {
    this.state -= 1;
    this.logger.log(`State decreased. Current state is ${this.state}.`);
  }
}
Enter fullscreen mode Exit fullscreen mode

We will then create instances of this class as follows:

const counter = new Counter(console);
Enter fullscreen mode Exit fullscreen mode

And if we want to replace console with something else, we just have to make sure that the new dependency implements the Logger interface:

const alertLogger: Logger = {
  log: (message: string): void => {
    alert(message);
  },
};

const counter = new Counter(alertLogger);
Enter fullscreen mode Exit fullscreen mode

Automatic Injections and DI Containers

Now the class doesn't use implicit dependencies. This is good, but this injection is still awkward: you have to add references to objects by hand every time, try not to mix up the order if there are several of them, and so on…

This should be handled by a special thing — a DI Container. In general terms, a DI Container is a module that only provides dependencies to other modules.

The container knows which dependencies which module needs, creates and injects them when needed. We free objects from the obligation to keep track of their dependencies, the control goes elsewhere, as the letters S and D in SOLID imply.

The container will know which objects with which interfaces are required by each of the modules. It will also know which objects implement those interfaces. And when creating objects that depend on such interfaces, the container will create them and access them automatically.

Automatic Injections in Practice

We will use the Brandi DI Container, which does exactly what we described above.

Let's begin by declaring the Logger interface and creating its ConsoleLogger implementation:

/* Logger.ts */

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

export class ConsoleLogger implements Logger {
  public log(message: string): void {
    console.log(message)
  }
}
Enter fullscreen mode Exit fullscreen mode

Now we need to get to know the tokens. Since we are compiling TypeScript into JavaScript, there will be no interfaces and types in our code. Brandi uses tokens to bind dependencies to their implementations in JavaScript runtime.

/* tokens.ts */

import { token } from 'brandi';

import { Logger } from './Logger';
import { Counter } from './Counter';

export const TOKENS = {
  logger: token<Logger>('logger'),
  counter: token<Counter>('counter'),
}; 
Enter fullscreen mode Exit fullscreen mode

The code above mentions the Counter. Let's see how dependencies are injected into it:

/* Counter.ts */

import { injected } from 'brandi';

import { TOKENS } from './tokens';
import { Logger } from './Logger';

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

  /* Other code... */
}

injected(Counter, TOKENS.logger);
Enter fullscreen mode Exit fullscreen mode

We use the injected function to specify by which token the dependency should be injected.

Since tokens are typed, it is not possible to inject a dependency with a different interface, this will throw an error at compile time.

Finally, let's configure the container:

/* container.ts */

import { Container } from 'brandi';

import { TOKENS } from './tokens';
import { ConsoleLogger } from './logger';
import { Counter } from './counter';

export const container = new Container();

container
  .bind(TOKENS.logger)
  .toInstance(ConsoleLogger)
  .inTransientScope();

container
  .bind(TOKENS.counter)
  .toInstance(Counter)
  .inTransientScope();
Enter fullscreen mode Exit fullscreen mode

We bound tokens to their implementation.

Now when we get an instance from the container, its dependencies will be injected automatically:

/* index.ts */

import { TOKENS } from './tokens';
import { container } from './container';

const counter = container.get(TOKENS.counter);

counter.increase() // -> State increased. Current state is 1.
Enter fullscreen mode Exit fullscreen mode

What's the inTransientScope()?

Transient is the kind of instance lifecycle that the container will create.

  • inTransientScope() — a new instance will be created each time it is gotten from the container;
  • inSingletonScope() — each getting will return the same instance.

Brandi also allows you to create instances in container and resolution scopes.

Benefits of The Container

The first benefit is that we can change the implementation in all modules with a single line. This way we achieve the control inversion that the last letter of SOLID talks about.

For example, if we want to change the Logger implementation in all the places that depend on this interface, we only need to change the binding in the container:

/* The new implementation. */
class AlertLogger implements Logger {
  public log(message: string): void {
    alert(message);
  }
}

/*
 * With this implementation we replace the old `ConsoleLogger`.
 * This only needs to be done in one place, at the token binding:
 */
container
  .bind(TOKENS.logger)
  .toInstance(AlertLogger)
  .inTransientScope();
Enter fullscreen mode Exit fullscreen mode

In addition, we don't pass dependencies by hand, we don't need to follow the order of enumeration in constructors, the modules become less coupled.

Should You Use DI?

Take some time to understand the potential benefits and tradeoffs of using DI. You will write some infrastructural code, but in return your code will be less coupled, more flexible and easier to test.

Discussion (0)