TSyringe and Dependency Injection in TypeScript
I am not a big fan of large frameworks like NestJS; I've always liked the freedom of building my software the way I want with the structure I decide in a lightweight way. But something I liked when testing NestJS out was the Dependency injection.
Dependency Injection (DI) is a design pattern that allows us to develop loosely coupled code by removing the responsibility of creating and managing dependencies from our classes. This pattern is crucial for writing maintainable, testable, and scalable applications. In the TypeScript ecosystem, TSyringe stands out as a powerful and lightweight dependency injection container that simplifies this process.
TSyringe is a lightweight dependency injection container for TypeScript/JavaScript applications. Maintained by Microsoft on their GitHub (https://github.com/microsoft/tsyringe), it uses decorators to do Constructor injection. Then, it uses an Inversion of Control container to store the dependencies based on a token that you can exchange for an instance or a value.
Understanding Dependency Injection
Before diving into TSyringe, let's briefly explore what dependency injection is and why it's important.
Dependency injection is a technique where an object receives its dependencies from external sources rather than creating them itself. This approach offers several benefits:
Improved testability: Dependencies can be easily mocked or stubbed in unit tests.
Increased modularity: Components are more independent and can be easily replaced or updated.
Better code reusability: Dependencies can be shared across different parts of the application.
Enhanced maintainability: Changes to dependencies have minimal impact on dependent code.
Setting Up TSyringe
First, let's set up TSyringe in your TypeScript project:
npm install tsyringe reflect-metadata
In your tsconfig.json
, ensure you have the following options:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Import reflect-metadata
at the entry point of your application:
import "reflect-metadata";
The entry point of your application is, for example, the root layout on Next.js 13+, or it can be the main file in a small Express application.
Implementing Dependency Injection with TSyringe
Let's take the example from the introduction and add the TSyringe sugar:
Let's start with the adapter.
// @/adapters/userAdapter.ts
import { injectable } from "tsyringe"
@injectable()
class UserAdapter {
constructor(...) {...}
async fetchByUUID(uuid) {...}
}
Notice the @injectable()
decorator? It's to tell TSyringe that this class can be injected at runtime.
So my Service is using the adapter we just created. Let's inject that Adapter into my Service.
// @/core/user/user.service.ts
import { injectable, inject } from "tsyringe"
...
@injectable()
class UserService {
constructor(@inject('UserAdapter') private readonly userAdapter: UserAdapter) {}
async fetchByUUID(uuid: string) {
...
const { data, error } = await this.userAdapter.fetchByUUID(uuid);
...
}
}
Here I also used the @injectable
decorator because the Service is going to be injected into my command class, but I also added the @inject
decorator in the constructor params. This decorator tells TSyringe to give the instance or the value it has for the token UserAdapter
for the userAdapter property at runtime.
And last but not least, the root of my Core: the command class (often wrongly called usecase).
// @/core/user/user.commands.ts
import { inject } from "tsyringe"
...
@injectable()
class UserCommands {
constructor(@inject('UserService') private readonly userService: UserService) {}
async fetchByUUID(uuid) {
...
const { data, error } = this.userService.fetchByUUID(uuid);
...
}
}
At this point, we've told TSyringe what is going to be injected and what to inject in the constructor. But we still haven't made our containers to store the dependencies. We can do that in two ways:
We can create a file with our dependency injection registry:
// @/core/user/user.dependencies.ts
import { container } from "tsyringe"
...
container.register("UserService", {useClass: UserService}) // associate the UserService with the token "UserService"
container.register("UserAdapter", {useClass: UserAdapter}) // associate the UserAdapter with the token "UserAdapter"
export { container }
But we can also use the @registry
decorator.
// @/core/user/user.commands.ts
import { inject, registry, injectable } from "tsyringe"
...
@injectable()
@registry([
{
token: 'UserService',
useClass: UserService
},
{
token: 'UserAdapter',
useClass: UserAdapter
},
])
export class UserCommands {
constructor(@inject('UserService') private readonly userService: UserService) {}
async fetchByUUID(uuid) {
...
const { data, error } = this.userService.fetchByUUID(uuid);
...
}
}
container.register("UserCommands", { useClass: UserCommands})
export { container }
Both methods have pros and cons, but at the end of the day, it's a matter of taste.
Now that our container is filled with our dependencies, we can get them from the container as needed by using the resolve method of the container.
import { container, UserCommands } from "@/core/user/user.commands"
...
const userCommands = container.resolve<UserCommands>("UserCommands")
await userCommands.fetchByUUID(uuid)
...
This example is pretty simple as each class only depends on another, but our services could depend on many, and dependency injection would really help keep everything tidy.
But wait! Don't leave me like that! How about the tests?
Testing with TSyringe
Our injections can also help us test our code by sending mock objects straight into our dependencies. Let's see a code example:
import { container, UserCommands } from "@/core/user/user.commands"
describe("test ftw", () => {
let userAdapterMock: UserAdapterMock
let userCommands: UserCommands
beforeEach(() => {
userAdapterMock = new UserAdapter()
container.registerInstance<UserAdapter>("UserAdapter", userAdapter)
userCommands = container.resolve<UserCommands>("UserCommands")
});
...
});
Now the UserAdapter
token contains a mock that will be injected into the dependent classes.
Best Practices and Tips
Use interfaces: Define interfaces for your dependencies to make them easily swappable and testable. I didn't use interfaces for the sake of simplicity in this article, but interfaces are life.
Avoid circular dependencies: Structure your code to avoid circular dependencies, which can cause issues with TSyringe.
Use tokens for naming: Instead of using string literals for injection tokens, create constant tokens:
export const USER_REPOSITORY_TOKEN = Symbol("UserRepository");
Scoped containers: Use scoped containers for request-scoped dependencies in web applications.
Don't overuse DI: Not everything needs to be injected. Use DI for cross-cutting concerns and configurable dependencies.
If you've come this far, I want to say thank you for reading. I hope you found this article instructive. Remember to always consider the specific needs of your project when implementing dependency injection and architectural patterns.
Likes and comment feedback are the best ways to improve.
Happy coding!
Subscribe to my newsletter
Read articles from Guillaume Dormoy directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by