It is expected that you are familiar with a package manager (like npm or pnpm, the latter of which I will use today) and have Node.js installed on your machine.
You should also be familiar with TypeScript and experimental decorators.
I will be using Node.js v20+ for this demo.
Getting started
Let's start by creating a new project:
# Create a new project
$ mkdir demo-inversifyjs
$ cd demo-inversifyjs
$ pnpm init
# Install dependencies
# Required for TypeScript
$ pnpm add -D typescript tsx @tsconfig/node20 @types/node
# Required for InversifyJS
$ pnpm add inversify reflect-metadata
# We will create a few config files, a main entry file and two different class files
$ touch tsconfig.json index.ts
Some notes on the above:
We install some TypeScript defaults and @tsconfig/node20 for our TypeScript configuration.
We install inversify and reflect-metadata for InversifyJS.
We create a tsconfig.json file for our TypeScript config, and an index.ts entry file. We will add more files as required.
Next, let's add some configuration to our tsconfig.json:
The extra compiler options added are required for InversifyJS to work. You will see the use of decorators in the section where we implement the code.
What problem is InversifyJS trying to solve
Directly from the InversifyJS documentation:
Our goal is to write code that adheres to the dependency inversion principle. This means that we should "depend upon Abstractions and do not depend upon concretions". Let's start by declaring some interfaces (abstractions).
Let's take a simple example of a file reader without InversifyJS. Let's say we have a FileReader class that reads from a file.
In the above example, we have a FileReader class that reads from a file. It has a parse method that takes in a string and returns a string.
The FileReader class has a dependency on a parser object that implements the IParser interface.
In our case, we use dependency injection to pass in the parser object to the FileReader class.
In a contrived example where we want one FileReader instance to be able to parse JSON while another simply returns the text, we could use the classes as follows:
import path from "node:path";
// ... omit existing code
class JsonParser implements IParser {
parse(data: string) {
return JSON.parse(data);
}
}
class FileParser implements IParser {
parse(data: string) {
return data;
}
}
async function main() {
const jsonParser = new JsonParser();
const jsonFileReader = new FileReader(jsonParser);
const fileParser = new FileParser();
const fileReader = new FileReader(fileParser);
const jsonFile = await jsonFileReader.read(
path.resolve(process.cwd(), "./data/example.json")
);
const file = await fileReader.read(
path.resolve(process.cwd(), "./data/example.txt")
);
console.log(jsonFileReader.parse());
console.log(fileReader.parse());
}
main();
If we now run the above code (let's assume it's in file-reader.ts), we get the following:
# Running the file
$ node file-reader.ts
{ example: 'data' }
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
In the above example, we displayed how to inject dependencies into a class. However, this can become cumbersome as the number of dependencies grows.
The full code is as follows:
import { readFile } from "node:fs/promises";
import path from "node:path";
interface IParser {
parse(data: string): string;
}
class JsonParser implements IParser {
parse(data: string) {
return JSON.parse(data);
}
}
class FileParser implements IParser {
parse(data: string) {
return data;
}
}
interface IFileReader {
read(filepath: string): Promise<string>;
parse(data: string): string;
}
class FileReader implements IFileReader {
private parser: IParser;
private contents?: string;
constructor(parser: IParser) {
this.parser = parser;
}
async read(filepath: string) {
this.contents = await readFile(filepath, "utf-8");
return this.contents;
}
parse() {
if (!this.contents) throw new Error("No contents to parse");
return this.parser.parse(this.contents);
}
}
async function main() {
const jsonParser = new JsonParser();
const jsonFileReader = new FileReader(jsonParser);
const fileParser = new FileParser();
const fileReader = new FileReader(fileParser);
await jsonFileReader.read(path.resolve(process.cwd(), "./data/example.json"));
await fileReader.read(path.resolve(process.cwd(), "./data/example.txt"));
console.log(jsonFileReader.parse());
console.log(fileReader.parse());
}
main();
InversifyJS helps us manage these dependencies by providing an inversion of control container.
An approach with InversifyJS
Let's refactor the above example to use InversifyJS. Copy the contents of file-reader.ts into another file file-reader-inversify.ts.
InversifyJS needs to use the type as identifiers at runtime. We use symbols as identifiers but you can also use classes and or string literals.
To do this, let's simply declare variable TYPES for the symbols of our interfaces. We will also add TAGS for our different parsers.
// ... omitted
// Our existing interfaces
interface IParser {
parse(data: string): string;
}
interface IFileReader {
read(filepath: string): Promise<string>;
parse(data: string): string;
}
// Our new symbols
const TYPES = {
IParser: Symbol.for("IParser"),
// Not entirely necessary for this example, but just to demonstrate how it works.
IFileReader: Symbol.for("IFileReader"),
};
const TAGS = {
JsonParser: "JsonParser",
FileParser: "FileParser",
};
// ... omitted
The next step is to use some decorators to help InversifyJS understand how to inject dependencies.
This is done with the injectable and inject decorators. We also use the named decorator to help with the TAGS we defined earlier.
import { injectable, inject, Container, named } from "inversify";
import "reflect-metadata";
import { readFile } from "node:fs/promises";
import path from "node:path";
// ... code omitted
// We update our classes
@injectable()
class JsonParser implements IParser {
parse(data: string) {
return JSON.parse(data);
}
}
@injectable()
class FileParser implements IParser {
parse(data: string) {
return data;
}
}
@injectable()
class FileReader implements IFileReader {
private parser: IParser;
private contents?: string;
constructor(@inject(TYPES.IParser) @named(TAGS.FileParser) parser: IParser) {
this.parser = parser;
}
async read(filepath: string) {
this.contents = await readFile(filepath, "utf-8");
return this.contents;
}
parse() {
if (!this.contents) throw new Error("No contents to parse");
return this.parser.parse(this.contents);
}
}
The @named decorator is used to help InversifyJS understand which parser to inject. We default to the FileParser in this case.
Next, we need to create a container and bind our interfaces to our classes.
// ... code omitted
// Create the container
const container = new Container();
container
.bind<IParser>(TYPES.IParser)
.to(JsonParser)
.whenTargetNamed(TAGS.JsonParser);
container
.bind<IParser>(TYPES.IParser)
.to(FileParser)
.whenTargetNamed(TAGS.FileParser);
container.bind<IFileReader>(TYPES.IFileReader).to(FileReader);
async function main() {
// Get the container with the tagged parser
const jsonFileReader = container.getNamed<IFileReader>(
TYPES.IFileReader,
TAGS.JsonParser
);
const fileReader = container.getNamed<IFileReader>(
TYPES.IFileReader,
TAGS.FileParser
);
await jsonFileReader.read(path.resolve(process.cwd(), "./data/example.json"));
await fileReader.read(path.resolve(process.cwd(), "./data/example.txt"));
console.log(jsonFileReader.parse());
console.log(fileReader.parse());
}
main();
In the above code, we configure the container to bind our interfaces to our classes. We also use the whenTargetNamed method to help InversifyJS understand which parser to inject.
Usually, this container configuration would be abstracted into another file (e.g. ioc.ts or something), but for the sake of this example, we have it all in one file.
Finally, we use the container.getNamed method to get the correct instance of the IFileReader class.
Once this is done, we can run the file:
# Running the file
$ npx tsx file-reader-inversify.ts
{ example: 'data' }
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
As we expected, the output is the same as before.
The final code for the file using InversifyJS is as follows:
In today's post, we went through a small example on how to use InversifyJS to help with dependency injection in our TypeScript projects. We initially wrote it without InversifyJS to have a comparison.
For myself, this was a review to better understand InversifyJS as it is used heavily within my new company.
In cases where dependencies increase and we need to manage them, InversifyJS can be a great tool to help manage these dependencies. That being said, I am yet to be sold on the approach.
Using getNamed() and tagging seems like it is adding a bit too much magic for my liking. I prefer to be explicit in my code and have the dependencies passed in directly.
There are a ton of extra features that I have not delved into with InversifyJS such as lifecycle management, middleware and more. I would recommend checking out the InversifyJS documentation for more information, which I will also do myself to get a better understanding of what is possible.
The commit graph is also lacking a little bit these days, but maybe they consider this project feature complete. I am a little wary of treading too far away from the JS/TS mainstream ecosystem, but I am also open to new ideas and ways of doing things.