DEV Community

Imamori Daichi
Imamori Daichi

Posted on

Dependency Injection for TypeScript

Ref

Abstract

Clean Architecture deals with concentric architecture with the business domain at the core, but
One of the key principles is the dependency constraint, which states that we should only depend on the inside from the outside.
However, in a straightforward implementation, the processing flow and dependencies would be in the same direction.
Of course, the Clean Architecture also requires processing from the inside to the outside, so a straightforward implementation will result in dependencies from the inside to the outside.
In other words, in order to observe the dependency principle, the flow of processing and the direction of dependency must be reversed.
The technique for solving this problem is called dependency inversion.
As the name suggests, this technique is used to satisfy the Dependency Inversion Principle (DIP).
So how do we reverse the dependency?
In this chapter, we introduce a technique called Dependency Injection (DI), which is commonly used to reverse dependencies.

DI library for TypeScript

Dependency Injection is a technique that has been used for a long time in statically typed languages such as Java.
You can build your own mechanism to realize DI, or you can use existing DI libraries (DI frameworks).
For example, in Java, there are many DI libraries such as Spring Framework and google/guice.
These DI libraries allow you to use DI for developing various applications.

How about in the context of JavaScript and TypeScript?
It may not be common to use DI in web frontend development.
Nevertheless, there are DI libraries available for JavaScript and TypeScript.
For example, AngularJS and Angular have a DI framework built in.
There are also libraries that provide DI functionality on its own, such as InversifyJS and TSyringe.
Both InversifyJS and TSyringe are DI libraries for use with TypeScript.
InversifyJS is a library with over 5000 Github stars and is used in over 18,000 repositories.
On the other hand, there doesn't seem to be much active development going on these days.
TSyringe is a DI library that is mainly developed by Microsoft. As of September 2020, it has about 1,400 stars on Github, and it seems to be under continuous development.

In this chapter, we will first introduce a simple method to perform DI without using libraries.
This simplified method uses a simple example to show what DI is trying to accomplish and what problems it has.
We will then explain how TSyringe can be used to solve these problems.

Simple DI without libraries

In this section, we will use a simple example to illustrate DI without using libraries.
The goal is to demonstrate an understanding of the overview of DI and the issues involved in DI.
However, as will be explained later, there are some practical problems with the method presented in this section.
In the next section, we will confirm that these problems are solved by DI using the library.

In this section, we will deal with a simple subject as follows.

import Printer from './Printer';

class Document {
  constructor(private content: string) {}

  output() {
    const printer = new Printer();
    printer.print(this.content);
  }
}
Enter fullscreen mode Exit fullscreen mode
export default class Printer {
  constructor() {}

  print(content: string) {
    console.log('Print:', content);
  }
}
Enter fullscreen mode Exit fullscreen mode

Document will output its own content using the output method.
In the code above, the output method uses the class Printer internally.
Printer prints out the received string by the print method.
However, in order to simplify the implementation, we only use console.log for the output.
The codes to use Document are as follows.

import Document from './Document';

const document = new Document('this is sample text');
document.output() // "Print: this is sample text"
Enter fullscreen mode Exit fullscreen mode

Looking at the dependencies, we see a dependency from Document to Printer.
Now let's assume that Email is added as an output of Document.
An example codes for this would look like the following

import Email from './Email';
import Printer from './Printer';

export default class Document {
  constructor(private content: string, private method: Printer | Email) {}

  output() {
    if (this.method instanceof Printer) {
      this.method.print(this.content);
    } else {
      this.method.send(this.content);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
export default class Email {
  constructor() {}

  send(content: string) {
    console.log('Email:', content);
  }
}
Enter fullscreen mode Exit fullscreen mode

New Email has been added, and Document now receives the output method in the constructor.
The dependency in this implementation is still the same as before, from Document to Printer.
In addition, Document now also depends on Email.
The code for the user side looks like this.

import Document from './Document';
import Printer from './Printer';
import Email from './Email';

const printerDocument = new Document('this is sample text', new Printer());
printerDocument.output(); // "Print: this is sample text"

const emailDocument = new Document('this is sample text', new Email());
emailDocument.output(); // "Email: this is sample text"
Enter fullscreen mode Exit fullscreen mode

As you may have already noticed, this implementation violates the DIP.
This is because Document depends on the concrete implementations Printer and Email.
This means that if Printer or Email is changed, Document will also be affected.
In a simple example, if you rename the print method of Printer, you will need to modify the output method of Document too.
Also, if you want to add more output methods for Document, as you did for Email, you need to modify Document.
This is a violation of the Open-Closed Principle (OCP): "open to extensions".
To solve the above problems, we will use DI to reverse the dependencies.
Reverse the dependency in the following steps.

  1. Create Document interface on DocumentOutputMethod side
  2. Printer and Email implement the DocumentOutputMethod interface
  3. Document implements the process using the interface
  4. Pass an instance of the class that implements the DocumentOutputMethod interface to the constructor of Document in the user side codes

This is called Dependency Injection (DI) because the instance to be depended on in step 4 is injected from the outside.
Let's take a look at an example implementation.

export interface DocumentOutputMethod {
  output: (content: string) => void;
}

export default class Document {
  constructor(private content: string, private method: DocumentOutputMethod) {}

  output() {
    this.method.output(this.content);
  }
}
Enter fullscreen mode Exit fullscreen mode

Added the DocumentOutputMethod interface under the management of Document.
And Document uses this DocumentOutputMethod to implement the output method.
By doing this, you can see that it no longer depends on specific implementations such as Printer or Email.

import { DocumentOutputMethod } from './Document';

export default class Printer implements DocumentOutputMethod {
  constructor() {}

  print(content: string) {
    console.log('Print:', content);
  }

  output(content: string) {
    this.print(content);
  }
}
Enter fullscreen mode Exit fullscreen mode
import { DocumentOutputMethod } from './Document';

export default class Email implements DocumentOutputMethod {
  constructor() {}

  send(content: string) {
    console.log('Email:', content);
  }

  output(content: string) {
    this.send(content);
  }
}
Enter fullscreen mode Exit fullscreen mode

Printer and Email implement the DocumentOutputMethod interface.
Added output method to satisfy the DocumentOutputMethod interface.
This means that Printer and Email now depend on Document.
The above fix reversed the dependency.

import Document from './Document';
import Printer from './Printer';
import Email from './Email';

const printerDocument = new Document(
  'this is sample text',
  new Printer()
);
printerDocument.output();

const emailDocument = new Document('this is sample text', new Email());
emailDocument.output()
Enter fullscreen mode Exit fullscreen mode

Finally, in the code of the user side, the constructor of Document is passed an instance of a class that implements the DocumentOutputMethod interface.
Now you can do DI without using the library.

Problems with this method

Actually, there is a problem with this method.
That is, when you instantiate Document, you need to instantiate and pass in Printer and Email.
This is another way of saying that any code that uses Document will always depend on Printer and Email.
In the above example, main.ts is affected by this, but
If you use Document in multiple locations, each location will depend on Printer and Email.
After removing the dependency on the concrete implementation from Document, this just shifts the dependency elsewhere.
To solve this problem, the dependency on concrete code should be gathered in as few places as possible, as described in Introduction to Clean Architecture for TypeScript: PART4.

So how do we aggregate our dependency on concrete code?
This can be solved by using an object called a DI container, which has a correspondence table of dependencies.
The DI container holds a list of correspondences between the class to be injected (Document in this case) and the class to inject (Printer or Email).
When instantiating the class to be injected, it automatically selects the class to inject from the correspondence table of the DI container, instantiates it, and passes it on.
Then, the description of the correspondence of the DI containers is integrated into the main component or configuration files.
In this way, the class to be injected (Document) can be used in various places while consolidating the dependencies on concrete code.

It is of course possible to implement a DI container without any libraries.
However, there are a number of technical issues that need to be resolved, and it is quite difficult to implement them on your own.
This is a brief bullet list of issues that need to be resolved.
If you are interested, please check out the details.

  • Need to use decorator
  • Metadata needs to be retrieved by reflection
  • TypeScript interface information is removed at compile time and cannot be mapped in the DI container.

These points have been resolved in the existing DI library.
In the next section, we will show an example of a DI that solves this problem by using TSyringe.

TSyringe

TSyringe is a DI library developed under the leadership of Microsoft.
As the name suggests, TSyringe allows you to introduce DI into your TypeScript development.

TypeScript Decorators

TSyringe uses an experimental feature in TypeScript called Decorators.
Decorators are in the Stage 2 Draft stage of JavaScript as of March 2021.
As such, there is room for change as a specification.
Similarly, TypeScript has been implemented it as an experimental feature, and its specifications are subject to change in the future.

So why does TSyringe use Decorators?
This is because the nature of the DI library is such that it works well with decorators (as a general language feature).
The DI library needs to know in some way the class to which it is injecting dependencies.
Before decorators were used, the injection target was set by a configuration file.
In this way, it is not possible to know what the DI settings are in the file where the class is declared.
There is also the cost of writing and managing configuration files.
By using decorators, these problems can be solved.
The following is an example of using TypeScript Decorators (not related to TSyringe).

function methodDecorator(target: any, props: string, descriptor: PropertyDescriptor) {
  console.log('This is method decorator f()');
}

function classDecorator(constructor: Function) {
  console.log('This is class decorator f()');
}

@classDecorator
class SampleClass {
    @methodDecorator
    hoge() {
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

In order to use Decorators in TypeScript, you need to add the following setting in the compiler options.

  {
    "compilerOptions": {
      "experimentalDecorators": true
    }
  }
Enter fullscreen mode Exit fullscreen mode

Installation and initial setup

This section describes the setup for using TSyringe.
The information here is based on official documentation.
First, install the package.

Install the package tsyringe by

$ npm install --save tsyringe
Enter fullscreen mode Exit fullscreen mode

or,

$ yarn add tsyringe
Enter fullscreen mode Exit fullscreen mode

TSyringe uses an experimental feature called Decorators in TypeScript, so we will rewrite tsconfig.js as follows

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
Enter fullscreen mode Exit fullscreen mode

We can use Decorators by enabling experimentalDecorators.
You can also enable emitDecoratorMetadata to allow the decorator to handle a lot of metadata.
To enable the Reflect API to handle metadata, you need to select one of the following libraries to use as a polyfill.

  • reflect-metadata
  • core-js (core-js/es7/reflect)
  • reflection

In this example, we install reflect-metadata as shown below,

$ npm install --save reflect-metadata
Enter fullscreen mode Exit fullscreen mode

or,

$ yarn add reflect-metadata
Enter fullscreen mode Exit fullscreen mode

reflect-metadata needs to be import only once before using DI.
It is a good idea to do import at the top level as much as possible.

import "reflect-metadata";
Enter fullscreen mode Exit fullscreen mode

Now you are ready to use TSyringe.
Next, let's look at how to actually do DI.

Define the class where you want to do DI

TSyringe provides a simple API for doing DI.
The implementation code differs depending on whether interface is used for the type of the object to be injected or not.
Here is an example of how to use interface.
For more information, please refer to the official documentation.

First, define the type to be injected with interface.

// apiInterface.ts
export interface User {
  id: number;
  name: string;
}

export interface UserApiInterface {
  fetchUser: (args: { id: number }) => User;
}
Enter fullscreen mode Exit fullscreen mode

UserApiInterface defines an API for retrieving user information.
It is assumed that the class that implements this interface includes processes such as communicating with the API server and connecting directly to the DB.
In the following, as a mock, we will implement a class that receives a user ID and returns static information.

// apiImpl.ts
import { UserApiInterface, User } from './apiInterface';

export class UserApiImpl implements UserApiInterface {
  fetchUser({ id }: { id: number }): User {
    return {
      id,
      name: `This is fetchUse of UserApi: ${id}`.
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Then, define the class in which UserApiInterface should be injected.

// api.ts
import { injectable, inject } from 'tsyringe';

import { UserApiInterface } from './apiInterface';

@injectable()
export class UserApi {
  constructor(
    @inject('UserApiInterface') private api: UserApiInterface
  ) {}

  fetchUser(args: { id: number }) {
    return this.api.fetchUser(args);
  }
}
Enter fullscreen mode Exit fullscreen mode

UserApi receives an object of type UserApiInterface in constructor as an argument and keeps it as a private member.
Then, in UserApi.fetchUser(), it calls api.fetchUser(), which is implemented in api.
In this way, UserApi can change its behavior depending on the object passed in constructor.
By writing @inject('UserApiInterface') in constructor, we pass the interface metadata to the DI library side.

Injecting an object

Now, inject the object into UserApi.

// main.ts
import { container } from 'tsyringe';

import { UseApiImpl } from './apiImpl';
import { UserApi } from './api';

container.register('UserApiInterface', {
  useClass: UserApiImpl
});

const userApi = container.resolve(UserApi);
Enter fullscreen mode Exit fullscreen mode

container is TSyringe's DI container, which holds the information to perform DI.
This container's register() method sets the object to be injected.
In this example, we are configuring UserApiInterface to inject UserApiImpl.
And finally, container.resolve(UseApi) instantiates UserApi.
At this time, container instantiates UserApiImpl and passes it to UserApi.

In other words, TSyringe is a mechanism to create instances while resolving dependencies (which registered by register()) by resolve().

This is the basic usage of TSyringe.
This DI mechanism can be used to reverse the dependency.

Dependency Inversion with DI Library

In the previous section, we presented an example of DI using TSyringe.
As many readers may have noticed from the implementation examples, using a DI library like TSyringe does not automatically reverse the dependencies.
The DI library does this by externally selecting and injecting the objects that a class depends on.
Therefore, it is possible to reverse the dependency by using it properly, but on the other hand, it is not possible to change the direction of the dependency if it is not used properly.
This section describes how to use the DI library to reverse the dependencies.

Design modules

user-database

First, let's design modules that will be the subject of our example.
In this section, we will consider the User model, which is the domain model, and Database, which is responsible for persisting the model.
User corresponds to the business logic and is located at the center of the concentric circles diagram.
And since Dataset is a concrete module, it is located outside the concentric diagram.
Therefore, in accordance with Clean Architecture, the dependency goes from Database to User.
This is represented in the diagram as the above image.
Database can know about User, but conversely, User cannot know about Database.
For the sake of illustration, let's assume that one file corresponds to one module, and create user.ts and database.ts.

First, let's define each one without considering the correct dependencies.

import Database from './database';

export default class User {
  private _id: number | null;

  private _name: string;

  constructor(private database: Database) {
    this._id = null;
    this._name = '';
  }

  set id(value: number) {
    this._id = value;
  }

  set name(value: string) {
    this._name = value;
  }

  get name() {
    return this._name;
  }

  save() {
    if (typeof this._id === 'number') {
      this.database.save(this);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

User can be set to id and name.
It persists itself via Database by User.save().

import User from './user';

export default class Database {
  save(user: User) {
    console.log(`${user.name} has been saved`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Database will receive the User model and persist it.
However, for the sake of simplicity, we will only display the received User's name in the console.

Let's take a look at the dependency between User and Database defined so far.
Database is import the type information of User.
User also uses Database as import, but
This is because User uses Database for persistence.
If you look at these dependencies, you can see that they violate the dependency rules.

user-database-bidirectional

Now, let's fix the dependency to the correct orientation.

Reversing dependencies between modules

First, define DatabaseInterface in user.ts.

export default class User {
  private _id: number | null;

  private _name: string;

  constructor(private database: DatabaseInterface) {
    this._id = null;
    this._name = '';
  }

  set id(value: number) {
    this._id = value;
  }

  set name(value: string) {
    this._name = value;
  }

  get name() {
    return this._name;
  }

  save() {
    if (typeof this._id === 'number') {
      this.database.save(this)
    }
  }
}

export interface DatabaseInterface {
  save: (user: User) => void;
}
Enter fullscreen mode Exit fullscreen mode

Change the constructor argument of User to DatabaseInterface.
This will remove import from user.ts to database.ts.
User is no longer dependent on Database.
Next, modify Database so that it implements DatabaseInterface.

import User, { DatabaseInterface } from './user';

export default class Database implements DatabaseInterface {
  save(user: User) {
    console.log(`${user.name} has been saved`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Database is importing User, which turns out to be fine according to the dependency rules we were aiming for.
Now we have satisfied the dependency rule.
The last step is to configure TSyringe.

import { injectable, inject } from 'tsyringe';

@injectable()
export default class User {
  private _id: number | null = null;

  private _name: string = '';

  constructor(
    @inject('DatabaseInterface')
    private database: DatabaseInterface
  ) {}

  set id(value: number) {
    this._id = value;
  }

  set name(value: string) {
    this._name = value;
  }

  get name() {
    return this._name;
  }

  save() {
    if (typeof this._id === 'number') {
      this.database.save(this);
    }
  }
}

$export interface DatabaseInterface {
  save: (user: User) => void;
}
Enter fullscreen mode Exit fullscreen mode

Added User with @injectable() and added database argument of constructor with the decorator @inject('DatabaseInterface').
Now you can inject Database into User by container.
Let's take a look at the code that uses these modules.

import { container } from 'tsyringe';
import User from './di/user';
import Database from './di/database';

container.register('DatabaseInterface', {
  useClass: Database
});

export const user = container.resolve(User);

user.id = 0;
user.name = 'test user';
user.save(); // 'test user saved' will be displayed in the console
Enter fullscreen mode Exit fullscreen mode

This reversed the dependency and helped us to follow the rules.
The important thing here is to define DatabaseInterface , on which User originally depends, in the User module.
In this way, User can manage DatabaseInterface within the scope of its own responsibilities without directly relying on Database.
In other words, if you define DatabaseInterface in the Database module, it will lose its meaning.

Now we know that with proper use of interfaces and DI, we can reverse the dependencies between modules.

Aggregating dependencies on concrete code

As described in the previous section, DI without libraries has a problem that it cannot aggregate dependencies on concrete code.
In the example in this section using TSyringe, we can aggregate container.register() into app.ts.
And by using container.resolve(User), the code on the side using User no longer depends on Database.
In this way, we can see that the problem that the simple DI without the library has been solved.

Ads

This article is an excerpt from the first chapter of the book "React Clean Architecture". Some wording has been changed. You can buy the kindle version of this book from amazon.

React Clean Architecture

Discussion (0)