Introduction
In this article I’m going to share with you an implementation of a powerful principle, made possible in JS by capabilities of TypeScript (and soon ECMAScript).
I’m talking about the Dependency Inversion Principle (DIP) and I will show you how it can elevate your JS game to new heights.
Dependency Inversion is a fundamental principle in software development that has been around for quite some time, but sometimes it can get overlooked amidst the constant introduction of new technologies and frameworks we’re bombarded with.
Although it is widely used in Backend development, it is not as commonly practiced in Frontend development. This is unfortunate, as Dependency Inversion has many benefits that make it a great approach to software development.
The principle of Dependency Inversion promotes the separation of concerns in software design, meaning that different aspects of a program are separated into distinct and independent components. This results in a more modular and maintainable code base, as changes to one component do not affect the rest of the program. Additionally, the use of Dependency Injection makes it easier to swap out dependencies for testing or alternative implementations, making the code more flexible and adaptable to changing requirements.
TypeScript places a strong emphasis on the utilization of Interfaces, however, it seems that many developers undervalue the full potential of this feature. When it comes to Object-Oriented Programming (OOP), Interfaces play a crucial role in enabling developers to program against an abstract API, without being concerned about the specific implementation that will ultimately be utilized.
That, with Dependency Injection, makes a powerful tool.
Inversion of Control?
It is common for us to develop a module that imports its dependencies, creates instances of these dependencies if necessary, and then utilizes their specific API.
This is referred to as a control flow, where the flow of control originates from high-level modules and moves towards lower-level ones.
In order to understand the caveats of this approach you need to dive a bit into the SOLID principles of software design. In particular the D in it, which stands for Dependency inversion principle, and if to simplify it -
- High-level modules should not depend on low-level modules. Both should depend on abstractions (an example of an abstraction can be an interface).
- Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
This concept was created in order to avoid code rigidness and tight coupling of modules, but if we are to put it in simple words, imagine the following scenario:
Imagine you've got a React app with a horde of modules, and a few of them use axios
as their trusty HTTP client. axios
has a certain API that you use throughout your application - each module imports it and calls its methods.
Now, this approach may seem like a good idea at first, but it's like bringing a very specific baggage along for the ride. Instead of just saying "I'm using an HTTP client", your modules scream "I'm using that specific HTTP client, and I'm the one responsible for importing it".
Fast forward to the day you want to switch from axios
to another library (or even the native Fetch API), and you'll find yourself in a jam. You'll have to hunt down every instance of axios
in your code, import the new library, and use its API. And if you want to test your module, you'll have to rely on your test framework to provide mock implementations - changing frameworks means changing the way you mock. It's a mess.
These issues are the reason why migrating from one dependency implementation to another can be a nightmare, leading to piles of legacy code and tech debt. But don't worry, this is far from being a new challenge in software dev. Software engineers have been battling with this problem for a while, and some smart cookies came up with a solution - Inversion of Control.
Instead of having high-level modules tightly coupled to lower-level ones, we invert the control flow by having both modules depend on an abstraction. This way, we can switch between implementations with ease, and test our modules without being tied to a specific testing framework.
Still hard to grasp? Follow me -
Translating this into real example
Module A, which uses axios
, should not depend on axios
, itself, but rather depend on an abstraction, like an Interface, and it won't import axios from 'axios'
but rather use a “factory” service which will return an instance of axios
to it.
All the code can be found in the npm-di repo on github
We are going to rely on 2 TypeScript capabilities, that will soon find their way into ECMASCript - Interfaces and Decorators
We start with a simple project, and we would like to set it to work with TypeScript, so we need to set the TS configuration.
Here is my tsconfig.json:
{
"compilerOptions": {
"outDir": "./dist/esm",
"declaration": true,
"declarationDir": "./dist/types",
"noImplicitAny": true,
"module": "ES2022",
"target": "ES6",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "node"
},
"files": ["index.ts"],
"exclude": ["node_modules"],
"include": ["src/**/*.ts"],
"ts-node": {
"esm": true
}
}
You might wonder why I’m still using the experimentalDecorators
and emitDecoratorMetadata
, since TS 5.0-beta was told to include them. It appears that it does but not for parameters, as stated here:
This new decorators proposal is not compatible with --emitDecoratorMetadata, and it does not allow decorating parameters. Future ECMAScript proposals may be able to help bridge that gap.
(and it was pure joy banging my head against my desk trying to figure out why it does not work as expected anymore…)
Now lets create the HTTPClient interface HTTPClient.ts
. This interface will represent how HTTP clients are expected to be in our project. This interface has a single method for now which is get()
which returns a promise:
export default interface HttpClient {
get: <T>(...rest: any) => Promise<T>;
}
Our project does a simple thing - it loads a list of Albums.
For that we set a class with the purpose of performing a get call to some fake endpoint and fetch the data. Let's call it “Data”.
Our Data class has a single “fetchData” method (note that this is not the final code, we will build it step by step):
import HttpClient from './HttpClient';
import {inject} from './di/inject';
type Album = {
userId: number;
id: number;
title: string;
};
interface AlbumsResponse {
data: Album[];
}
class Data {
httpClient: HttpClient;
fetchData() {
return new Promise<Album[]>((resolve, reject) => {
this.httpClient
.get<AlbumsResponse>('https://jsonplaceholder.typicode.com/albums')
.then((response) => {
resolve(response.data);
})
.catch((error) => {
reject(error);
});
});
}
}
export default Data;
As you can see it defines an instance member with the type of HttpClient interface. Then in the “fetchData” method, it calls its “get” method.
If you try to invoke this method from the index.ts
file, like so:
import Data from './src/Data';
async function getData() {
const data = new Data();
const result = await data.fetchData();
console.log('result :>> ', result);
}
getData();
You will get this error:
/home/matti/my/projects/npm-dip/src/Data.ts:10
.get('https://jsonplaceholder.typicode.com/albums')
^
TypeError: Cannot read properties of undefined (reading 'get')
at Data.fetchData (/home/matti/my/projects/npm-dip/src/Data.ts:10:14)
And rightfully so.
It is time to do some injection magic…
Dependency injection (DI)
Our DI is divided into 3 parts -
- We have the decorator definition, which is the
@inject
decorator. - We have the container which is a singleton that the decorator function uses in order to fetch the corresponding implementation according to the interface.
- We have the configuration which defines what implementation goes to what interface.
Let’s start with the configuration:
import axios from 'axios';
export type DiConfig = Record<string, any>;
const config: DiConfig = {
HttpClient: axios,
};
export default config;
The configuration is a simple key/value object that maps an interface name to an implementation. Here we’re mapping HttpClient
to axios
(obviously we need to install it using npm/yarn).
Now, let’s create the DI container:
import config, {DiConfig} from './config';
export class Container {
static instance: Container;
configuration: DiConfig = config;
static getInstance(): Container {
if (!this.instance) {
this.instance = new Container();
}
return this.instance;
}
getImplementationByInterface(interfaceName: string) {
return this.configuration[interfaceName];
}
}
As you can see, we’re having the configuration hard-coded in the container, but it is possible to load the configuration dynamically into the container, either on build time, or even on runtime, but this is not in the scope of this article.
We can now jump to the inject
decorator:
import {Container} from './Container';
const diContainer: Container = Container.getInstance();
// NOTE: the annotation cannot read the interface from
// the declaring line and this is why we pass it as an arg
// Would be nice if it could though ;)
export function inject(interfaceName: string) {
return (target: any, propertyKey: string) => {
target[propertyKey] = diContainer.getImplementationByInterface(interfaceName);
};
}
This function gets the required interface name and then changes the implementation of the class which used this decorator. It will set a value to the member, fetched from the DI container.
In other words, it will convert this: httpClient: HttpClient;
to this: httpClient: HttpClient = axios;
, but our “Data” module is oblivious to that. It does not import “axios” nor is it aware of its concrete implementation.
Are you beginning to understand the power hidden here?
We move to the Data class where we would like to use our decorator. We add the @inject(‘HttpClient’)
decorator above the relevant class member:
@inject('HttpClient')
httpClient: HttpClient;
(I know, it would’ve been great if I could just “say” inject without declaring the interface, since we already got it as the type definition, but this seems to be not possible to get that detail on the decorator code. Perhaps you have an idea?).
Does it work?
In order to check if it works I would like to compile TS and then bundle the result into a single file. Once bundled, I can run it in node
and see the result. For that I’m using TSC, esbuild and script commands defined in my package.json
. Here’s how it looks:
{
"name": "npm-di",
"version": "1.0.0",
"description": "Dependency injection for NPM",
"main": "index.js",
"scripts": {
"build": "tsc",
"bundle": "yarn build && esbuild dist/esm/index.js --bundle --platform=node --minify --sourcemap --outfile=dist/main/index.js",
"start": "node dist/main/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Matti Bar-Zeev",
"license": "ISC",
"devDependencies": {
"esbuild": "^0.17.5",
"typescript": "^4.3.2"
},
"dependencies": {
"axios": "^1.2.5"
}
}
I run yarn bundle
and then yarn start
and sure enough node console outputs the 100 records list :)
Injecting a different implementation
This is very nice, but does it really work? I mean, what if I would like to use a different implementation of the HttpClient interface? Let’s try that -
I’m creating a new implementation called “CustomHttpClient” which uses fetch instead of axios
import HttpClient from './HttpClient';
interface ResponseData {
data: any;
}
const CustomHttpClient: HttpClient = {
get<T>(url: string): Promise<T> {
console.log('Fetching data with CustomHttpClient');
return new Promise<T>(async (resolve, reject) => {
const response = await fetch(url);
const data = await response.json();
resolve({data} as T);
});
},
};
export default CustomHttpClient;
And in the configuration I’m changing mapping between the HttpClient
interface to the new implementation:
import axios from 'axios';
import CustomHttpClient from '../CustomHttpClient';
export type DiConfig = Record<string, any>;
const config: DiConfig = {
// HttpClient: axios,
HttpClient: CustomHttpClient,
};
export default config;
I’m running it and yeah! It now fetches the data using the new implementation.
I didn’t change anything in the Data class. The Data class is no longer responsible for instantiating or depending on a specific implementation.
The bad control flow is no more!
But… what gets bundled in the end?
As you saw I’m using esbuild for bundling, but I would like to see how this methodology affects the final bundle. Will it tree-shake the unrequired imports?
I first build the app with “axios” in the DI configuration, and after I bundle it I get my bundle with the size of:
dist/main/index.js 193.7kb
Now I will do the same, but with my CustomHttpClient
, and let’s see if the bundle is affected somehow. The size now is:
dist/main/index.js 1.9kb
In both cases I’m importing both axios
and CustomHttpClient
.
Nice :) we’re bundling only what is injected although the decorator.
Wrapping up
Inversion of Control (IoC) using Dependency Injection (DI) is considered a good thing in software development as it promotes separation of concerns, improves modularity and testability of code, and reduces tight coupling between components.
DI provides a means of loosely coupling the dependencies and makes it easier to swap them out for testing or for alternative implementations. It results in more flexible and maintainable code, making it easier for developers to evolve and adapt to changing requirements.
We saw that we can use this approach on the Frontend as well with the help of TypeScript Interfaces and Decorators, which will soon find their way into ECMAScript standards, so the future is bright.
Have a go at this, and let me know what you think.
All the code can be found in the npm-di repo on github
Hey! for more content like the one you've just read check out @mattibarzeev on Twitter 🍻
Photo by Benjamin Thomas on Unsplash
Top comments (2)
Great article, thanks a lot!
One nitpick, because I think it's more pertinent than just a nit.
DIP allows for Dependency Injectors to be written, but the end goal of DIP is not Injection.
To wit, an injector is also a low-level thing, which then needs to load and handle the higher level things. Even if it's meta and uses other language features to do so. You end up tightly coupled to your injector. Just look to how miserable Angular testing has been, with test files hundreds of lines long, being relatively common, as people attempt to appease the injector that is bound for the purpose of prod (not for dev or testing or anything else).
Further, Martin (in "Clean Architecture") goes so far as to say that if you are using an injector, it should only be used in the bootstrap layer of the codebase, and the rest of the way it should be passed down, et cetera.
Dependency Inversion is amazing; as a huge functional programming fan, I would even say foundationally required, but I hazard to tie the concept to containers that are preconfigured to do one thing, one way, in the codebase. Especially if you are directly annotating classes / functions / code files, themselves, that don't need to know anything about the container... doubly if there are multiple potential environments and that container is hardwired to work for only one.