Dependency injection in Typescript

September 27, 2021

What is Dependency Injection?

Dependency Injection is one of the ways to manage the dependencies of your application. The main idea of DI revolves around passing dependencies to the class or function instead of making those entities instantiating them. DI makes a lot easier designing classes in our application. Not only it helps to resolve any dependency issues, it also makes our code testable. Writing tests won't be a hustle anymore.

Why would I use Dependency Injection?

Through DI we can reduce complexity of our code. First our classes and functions don't have to carry the responsibility of instating complex operations. Dependencies represent some degree of a risk. Coupling our existing code may make our code too dependent on some library that we started using. After some time we may realize that this particular library is not what we really want. It maybe that library is outdated, not maintained anymore or even has some security issue. Tightly coupling our code with dependencies may lead to that.

Coupling tightly our code with dependencies

Imagine we are working on a project that's one of the responsibilities is to watch and manage some physical device through USB cable. Our project will be written in Typescript with the help of some external library that enables our Node application understand whether any USB device was connected or if any data was sent through the cable. Start with the "tightly coupled example". In my experience writing such a code comes either with lack of experience or when we don't think much about design of our application. It's nothing wrong with that. Context is very important in that case. Your task may be to explore some idea, write MVP code and show your results. In that case DI won't be your concern, but in situation when your project will last for a lot of months designing a proper dependency managing solution will help your team resolve many issues.

// real-usb-device-listener.ts
import usb, { Device } from 'usb';
import { EventEmitter } from "events"

export class RealUsbDeviceListener {
  private readonly devices: Device[] = [];

  constructor(private readonly eventEmitter: EventEmitter = new EventEmitter()) {}

  async startListening(_timeout?: number): Promise<void> {
    usb.on('attach', (device: Device) => {
      const {idVendor, idProduct} = device.deviceDescriptor;
      this.devices.push(device);
      const portInfo = {
        vendorId: idVendor.toString(16).padStart(4, '0'),
        productId: idProduct.toString(16).padStart(4, '0'),
      };
      eventEmitter.emit("deviceAttached", portInfo)
    });
  }
}

At the beginning of our imaginary project our goal is to write a class that will instantiate initial connection with the external device. After that we want to have some sort of EventBus which will emit events to some to a different parts of the app. EventEmitter dependency is directly imported into the file and used in startListening method. We may even try to mock usb library but I felt it was too low level even to attempt that. Code works as intended, but how do we test it? In this situation to somehow reliable test our code is to mock it. The initial setup of mocked module could look something like that:

// real-usb-device-listener.test.ts
const emit = jest.fn()
jest.mock("events", () => {
  return {
    emit,
    // some other needed methods
  }
});

test('event bus captures events that are emitted on "attach"', async () => {
  const eventBus = new InMemoryEventBus();
  const usbListener = new RealUsbDeviceListener(eventBus);
  await usbListener.startListening();
  await waitForExpect(async () => {
    await expect(emit).toHaveBeenCalled();
  });
});

This approach may lead to some problems:

  • We have to rely on features of the test runner that we are using at the moment, in our case it is jest. Maybe in future we'll have some other very popular test runner that will replace the Jest. In that case we'll have to rewrite our tests if we were to keep maintaining the project.
  • In situation when our code has a lot of test cases and a lot of mocks, tests tend to bleed into each other. It means that we have to keep resetting mocks and instantiating them over again if the values that we want to return change. If we forget to reset some mock it may lead to situation in which one test will use mocked module from the previous test. In this situation developer can be confused and ask many unnecessary questions, like why this test is not working if i did set it up correctly?. This developer kinda did set it up one test correctly, but unfortunately one of his previous tests made a big mess. Now developer has to spend time on rereading previous tests to somehow debug hers issue, but it may take some time if there are already many test cases written.

How can I apply Dependency Injection in my project?

Pure typescript

Let's explore how we can apply DI in our project. There are many approaches that I found in my career to be working that serve as Dependency Injection principle executor. This method relies on writing the class/function that will implement interface of the actual implementation. Start wit writing initial interface of EventBus.

// event-bus.ts
interface EventBus {
  emitEvent<T>(eventType: string, eventPayload: T): void
  listenForEvent<T>(eventType: string, callback: (eventPayload: T) => void): void
}

Then we'll start writing the implementation:

// event-bus.ts
import { EventEmitter } from "events"

interface EventBus {
  emitEvent<T>(eventType: string, eventPayload: T): void
  listenForEvent<T>(eventType: string, callback: (eventPayload: T) => void): void
}

export class EventEmitterEventBus implements EventBus {
  constructor(private readonly eventEmitter: EventEmitter = new EventEmitter()) {}

  emitEvent<T>(eventType: string, eventPayload: T): void {
    this.eventEmitter.emit(eventType, eventPayload)
  }

  listenForEvent<T>(eventType: string, callback: (eventPayload: T) => void): void {
    this.eventEmitter.on(eventType, callback)
  }
}
// in-memory-event-bus.ts
import { EventBus } from './event-bus'

interface CapturedEvent {
  type: string
  payload: any
}

export class InMemoryEventBus implements EventBus {
  readonly capturedEvents: CapturedEvent[] = []

  emitEvent<T>(eventType: string, eventPayload: T): void {
    this.capturedEvents.push({ type: eventType, payload: eventPayload })
  }
}

Our goal was to create a class that will exactly implement the interface that we expected. Then we add some arbitrary code that resemblance that implementation. Here we create the array that will hold any attach events that happen through our usb cable.

Implementation code has to be changed if we want to use DI. EventBus will be now instantiated outside of our DeviceListener class.

import usb, { Device } from 'usb';
import { UsbDeviceListener } from './usb-device-listener';
import { EventBus } from './event-bus';

export class RealUsbDeviceListener implements UsbDeviceListener {
  private readonly devices: Device[] = [];

  constructor(private readonly eventBus: EventBus) {}

  async startListening(_timeout?: number): Promise<void> {
    usb.on('attach', (device: Device) => {
      const {idVendor, idProduct} = device.deviceDescriptor;
      this.devices.push(device);
      const portInfo = {
        vendorId: idVendor.toString(16).padStart(4, '0'),
        productId: idProduct.toString(16).padStart(4, '0'),
      };
      this.eventBus.emitEvent("deviceAttached", portInfo)
    });
  }
}
import { RealUsbDeviceListener } from '../src/real-usb-device-listener';
import { InMemoryEventBus } from '../src/in-memory-event-bus';
import waitForExpect from "wait-for-expect";

jest.setTimeout(500000)
waitForExpect.defaults.timeout = 500000

test('event bus captures events that are emitted on "attach"', async () => {
  const eventBus = new InMemoryEventBus();
  const usbListener = new RealUsbDeviceListener(eventBus);
  await usbListener.startListening();
  await waitForExpect(async () => {
    await expect(eventBus.capturedEvents).toHaveLength(1);
  });
});

Conclusion

Test works as intended. Because of the nature of our project test needed some timeouts and awaiting for events to happen because we had to manually attach the device when test was running. I'm aware that there are different methods, but I'm not embedded engineer, and it was enough for me. It was a great fun. Keep in mind that this test required external device. If your code only tests your implementation, it will be even easier in your case. Asynchronous actions won't require outside action, you will only have to await them.