As a software developer who started my career with Java, I had issues during my transition to JavaScript. The original environment lacked a static type system and had virtually no support for containerized dependency injection, causing me to write code that was prone to obvious bugs and barely testable.
TypeScript’s compile-time type system changed it all, allowing for the continuous development of complex projects. It enabled the reemergence of design patterns like dependency injection, typing and passing dependencies correctly during object construction, which promotes more structured programming and facilitates writing tests without monkey patching.
In this article, we’ll review five containerized dependency injection tools for writing dependency injection systems in TypeScript. Let’s get started!
To follow along with this article, you should be familiar with the following concepts:
Interfaces allow developers to decouple abstraction requirements from actual implementation, which helps tremendously in writing tests. Note that interfaces define only functionality, not dependencies. Lastly, interfaces leave no runtime trace, however, classes do.
Let’s consider three example interfaces:
export interface Logger { log: (s: string) => void; } export interface FileSystem<D> { createFile(descriptor: D, buffer: Buffer): Promise<void>; readFile(descriptor: D): Promise<Buffer>; updateFile(descriptor: D, buffer: Buffer): Promise<void>; deleteFile(descriptor: D): Promise<void>; } export interface SettingsService { upsertSettings(buffer: Buffer): Promise<void>; readSettings(): Promise<Buffer>; deleteSettings(): Promise<void>; }
The Logger
interface abstracts synchronous logging away, while the generic FileSystem
interface abstracts file CRUD operations away. Finally, the SettingsService
interface provides a business-logic abstraction over settings management.
We can infer that any implementation of the SettingsService
depends on some implementations of the Logger
and the FileSystem
interfaces. For example, we could create a ConsoleLogger
class to print logs to the console output, create a LocalFileSystem
to manage the files on the local disc, or create a SettingsTxtService
class to write application settings to a settings.txt
file.
Dependencies can be passed explicitly using special functions:
export class ConsoleLogger implements Logger { // ... } export class LocalFileSystem implements FileSystem<string> { // ... } export class SettingsTxtService implements SettingsService { protected logger!: Logger; protected fileSystem!: FileSystem<string>; public setLogger(logger: SettingsTxtService["logger"]): void { this.logger = logger; } public setFileSystem(fileSystem: SettingsTxtService["fileSystem"]): void { this.fileSystem = fileSystem; } // ... } const logger = new ConsoleLogger(); const fileSystem = new LocalFileSystem(); const settingsService = new SettingsTxtService(); settingsService.setLogger(logger); settingsService.setFileSystem(fileSystem);
The SettingsTxtService
class does not depend on implementations like ConsoleLogger
or LocalFileSystem
. Instead, it depends on the aforementioned interfaces, Logger
and FileSystem<string>
.
However, explicitly managing dependencies poses a problem for every DI container because interfaces do not exist in runtime.
Most injectable components of any system depend on other components. You should be able to draw a graph of them at any time, and the graph of a well-thought-out system will be acyclic. Based on my experience, cyclic dependencies are a code smell, not a pattern.
The more complex a project becomes, the more complex the dependency graphs become. In other words, explicitly managing dependencies does not scale well. We can remedy this by automating dependency management, which makes it implicit. To do so, we’ll need a DI container.
A DI container requires the following:
ConsoleLogger
class with the Logger
interfaceLocalFileSystem
class with the FileSystem<string>
interfaceSettingsTxtService
on both the Logger
and the FileSystem<string>
interfacesBinding a specific type or class to a specific interface in runtime can occur in two ways:
For example, we could explicitly state that the ConsoleLogger
class is associated with the logger
token using the container’s API. Alternately, we could use a class-level decorator that accepts the token name as its parameter. The decorator would then use the container’s API to register the binding.
If the Logger
interface becomes an abstract class, we could apply a class-level decorator to it and all of its derived classes. In doing so, the decorators would call the container’s API to track the associations in runtime.
Resolving dependencies in runtime is possible in two ways:
We’ll focus on the first option. A DI container is responsible for instantiating and maintaining every component’s lifecycle. Therefore, the container needs to know where to inject dependencies.
We have two ways to provide this information:
Although decorators and metadata, like the Reflect API, are experimental features, they reduce overhead when using DI containers.
Now, let’s look at five popular containers for dependency injection. Note that the order used in this tutorial reflects how DI evolved as a pattern while being applied in the TypeScript community.
The Typed Inject project focuses on type safety and explicitness. It uses neither decorators nor decorator metadata, opting instead for manually declaring dependencies. It allows for multiple DI containers to exist, and dependencies are scoped either as singletons or as transient objects.
The code snippet below outlines the transition from the contextual DI, which was shown in previous code snippets, to the Typed Inject DI:
export class TypedInjectLogger implements Logger { // ... } export class TypedInjectFileSystem implements FileSystem<string> { // ... } export class TypedInjectSettingsTxtService extends SettingsTxtService { public static inject = ["logger", "fileSystem"] as const; constructor( protected logger: Logger, protected fileSystem: FileSystem<string>, ) { super(); } }
The TypedInjectLogger
and TypedInjectFileSystem
classes serve as concrete implementations of the required interfaces. Type bindings are defined on the class-level by listing object dependencies using inject
, a static variable.
The following code snippet demonstrates all major container operations within the Typed Inject environment:
const appInjector = createInjector() .provideClass("logger", TypedInjectLogger, Scope.Singleton) .provideClass("fileSystem", TypedInjectFileSystem, Scope.Singleton); const logger = appInjector.resolve("logger"); const fileSystem = appInjector.resolve("fileSystem"); const settingsService = appInjector.injectClass(TypedInjectSettingsTxtService);
The container is instantiated using the createInjector
functions, with token-to-class bindings declared explicitly. Developers can access instances of provided classes using the resolve
function. Injectable classes can be obtained using the injectClass
method.
The InversifyJS project provides a lightweight DI container that uses interfaces created through tokenization. It uses decorators and decorators’ metadata for injections. However, some manual work is still necessary for binding implementations to interfaces.
Dependency scoping is supported. Objects can be scoped either as singletons or transient objects, or bound to a request. Developers can use separate DI containers if necessary.
The code snippet below demonstrates how to transform the contextual DI interface to use InversifyJS:
export const TYPES = { Logger: Symbol.for("Logger"), FileSystem: Symbol.for("FileSystem"), SettingsService: Symbol.for("SettingsService"), }; @injectable() export class InversifyLogger implements Logger { // ... } @injectable() export class InversifyFileSystem implements FileSystem<string> { // ... } @injectable() export class InversifySettingsTxtService implements SettingsService { constructor( @inject(TYPES.Logger) protected readonly logger: Logger, @inject(TYPES.FileSystem) protected readonly fileSystem: FileSystem<string>, ) { // ... } }
Following the official documentation, I created a map called TYPES
that contains all the tokens we’ll use later for injection. I implemented the necessary interfaces, adding the class-level decorator @injectable
to each. The parameters of the InversifySettingsTxtService
constructor use the @inject
decorator, helping the DI container to resolve dependencies in runtime.
The code for the DI container is seen in the code snippet below:
const container = new Container(); container.bind<Logger>(TYPES.Logger).to(InversifyLogger).inSingletonScope(); container.bind<FileSystem<string>>(TYPES.FileSystem).to(InversifyFileSystem).inSingletonScope(); container.bind<SettingsService>(TYPES.SettingsService).to(InversifySettingsTxtService).inSingletonScope(); const logger = container.get<InversifyLogger>(TYPES.Logger); const fileSystem = container.get<InversifyFileSystem>(TYPES.FileSystem); const settingsService = container.get<SettingsTxtService>(TYPES.SettingsService);
InversifyJS uses the fluent interface pattern. The IoC container achieves type binding between tokens and classes by declaring it explicitly in code. Getting instances of managed classes requires only one call with proper casting.
The TypeDI project aims for simplicity by leveraging decorators and decorator metadata. It supports dependency scoping with singletons and transient objects and allows for multiple DI containers to exist. You have two options for working with TypeDI:
Class-based injections allow for the insertion of classes by passing interface-class relationships:
@Service({ global: true }) export class TypeDiLogger implements Logger {} @Service({ global: true }) export class TypeDiFileSystem implements FileSystem<string> {} @Service({ global: true }) export class TypeDiSettingsTxtService extends SettingsTxtService { constructor( protected logger: TypeDiLogger, protected fileSystem: TypeDiFileSystem, ) { super(); } }
Every class uses the class-level @Service
decorator. The global
option means all classes will be instantiated as singletons in the global scope. The constructor parameters of the TypeDiSettingsTxtService
class explicitly state that it requires one instance of the TypeDiLogger
class and one of the TypeDiFileSystem
class.
Once we have declared all dependencies, we can use TypeDI containers as follows:
const container = Container.of(); const logger = container.get(TypeDiLogger); const fileSystem = container.get(TypeDiFileSystem); const settingsService = container.get(TypeDiSettingsTxtService);
Token-based injections bind interfaces to their implementations using a token as an intermediary. The only change in comparison to class-based injections is declaring the appropriate token for each construction parameter using the @Inject
decorator:
@Service({ global: true }) export class TypeDiLogger extends FakeLogger {} @Service({ global: true }) export class TypeDiFileSystem extends FakeFileSystem {} @Service({ global: true }) export class ServiceNamedTypeDiSettingsTxtService extends SettingsTxtService { constructor( @Inject("logger") protected logger: Logger, @Inject("fileSystem") protected fileSystem: FileSystem<string>, ) { super(); } }
We have to construct the instances of the classes we need and connect them to the container:
const container = Container.of(); const logger = new TypeDiLogger(); const fileSystem = new TypeDiFileSystem(); container.set("logger", logger); container.set("fileSystem", fileSystem); const settingsService = container.get(ServiceNamedTypeDiSettingsTxtService);
The TSyringe project is a DI container maintained by Microsoft. It is a versatile container that supports virtually all standard DI container features, including resolving circular dependencies. Similar to TypeDI, TSyringe supports class-based and token-based injections.
Developers must mark the target classes with TSyringe’s class-level decorators. In the code snippet below, we use the @singleton
decorator:
@singleton() export class TsyringeLogger implements Logger { // ... } @singleton() export class TsyringeFileSystem implements FileSystem { // ... } @singleton() export class TsyringeSettingsTxtService extends SettingsTxtService { constructor( protected logger: TsyringeLogger, protected fileSystem: TsyringeFileSystem, ) { super(); } }
The TSyringe containers can then resolve dependencies automatically:
const childContainer = container.createChildContainer(); const logger = childContainer.resolve(TsyringeLogger); const fileSystem = childContainer.resolve(TsyringeFileSystem); const settingsService = childContainer.resolve(TsyringeSettingsTxtService);
Similar to other libraries, TSyringe requires programmers to use constructor parameter decorators for token-based injections:
@singleton() export class TsyringeLogger implements Logger { // ... } @singleton() export class TsyringeFileSystem implements FileSystem { // ... } @singleton() export class TokenedTsyringeSettingsTxtService extends SettingsTxtService { constructor( @inject("logger") protected logger: Logger, @inject("fileSystem") protected fileSystem: FileSystem<string>, ) { super(); } }
After declaring target classes, we can register token-class tuples with the associated lifecycles. In the code snippet below, I’m using a singleton:
const childContainer = container.createChildContainer(); childContainer.register("logger", TsyringeLogger, { lifecycle: Lifecycle.Singleton }); childContainer.register("fileSystem", TsyringeFileSystem, { lifecycle: Lifecycle.Singleton }); const logger = childContainer.resolve<FakeLogger>("logger"); const fileSystem = childContainer.resolve<FakeFileSystem>("fileSystem"); const settingsService = childContainer.resolve(TokenedTsyringeSettingsTxtService);
NestJS is a framework that uses a custom DI container under the hood. It is possible to run NestJS as a standalone application as a wrapper over its DI container. It uses decorators and their metadata for injections. Scoping is allowed, and you can choose from singletons, transient objects, or request-bound objects.
The code snippet below includes a demonstration of NestJS capabilities, starting from declaring the core classes:
@Injectable() export class NestLogger implements Logger { // ... } @Injectable() export class NestFileSystem extends FileSystem<string> { // ... } @Injectable() export class NestSettingsTxtService extends SettingsTxtService { constructor( protected logger: NestLogger, protected fileSystem: NestFileSystem, ) { super(); } }
In the code block above, all targeted classes are marked with the @Injectable
decorator. Next, we defined the AppModule
, the core class of the application, and specified its dependencies, providers
:
@Module({ providers: [NestLogger, NestFileSystem, NestSettingsTxtService], }) export class AppModule {}
Finally, we can create the application context and get the instances of the aforementioned classes:
const applicationContext = await NestFactory.createApplicationContext( AppModule, { logger: false }, ); const logger = applicationContext.get(NestLogger); const fileSystem = applicationContext.get(NestFileSystem); const settingsService = applicationContext.get(NestSettingsTxtService);
In this tutorial, we covered what a dependency injection container is, and why you would use one. We then explored five different dependency injection containers for TypeScript, learning how to use each with an example.
Now that TypeScript is a mainstream programming language, using established design patterns like dependency injection may help developers transition from other languages.
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
5 Replies to "Top 5 TypeScript dependency injection containers"
This post misses the most important part of DI that most DI in java does which is overrides.
Using Guice as an example.
production:
Injector injector = Guice.createInjector(new ProductionModule());
App appTree = injector.createClass(App.class);
testing
Injector injector = Guice.createInjector(Modules.override(new ProductionModule()).with(new TestOverrides());
App appTreeWithMocks = injector.createClass(App.class)
which of the above DI frameworks do this? This is a critical piece of the usefulness of DI.
If I understood you correctly, you want to inject a different class at different runtimes (production and test).
For Next.js, we can check how their did that for their testing, for instance in this test suite:
https://github.com/nestjs/nest/blob/master/integration/hello-world/e2e/guards.spec.ts
For others DI libraries, we can use child injectors or child containers:
* https://github.com/nicojs/typed-inject#-child-injectors
* https://github.com/nicojs/typed-inject#-child-injectors
In general, the TS/JS ecosystem is less mature compared to Java’s one, also based on my experience DI is not a vastly used pattern in TS codebases.
All five of these rely on `public static` members or decorators – meaning all of these rely on your designing code for the DI container. DI containers should be able to work in general – and with third party code in particular.
They should not require a footprint in the code outside of the DI bootstrap code, near the entry point of the program – domain code should not need to concern itself with dependency injection at all, it should be handled centrally, in one location, not all over the place.
A few other containers have tried to get around the issue by adding reflection to the language – usually, this relies on plugins for the official TS compiler, which almost no one uses in practice (the compilation space is largely dominated by Babel, ESBuild, SWC, or other tools that integrate these) and with the plugin API itself remaining an unofficial/internal feature of the official compiler, so that doesn’t really work for me either.
Here’s one container I’ve seen that doesn’t rely on decorators or compilation:
https://brandi.js.org/
I wrote an article about doing dependency injection without a DI container, which is also an option:
https://dev.to/mindplay/a-successful-ioc-pattern-with-functions-in-typescript-2nac
This article talks about functions, but the pattern is applicable to classes or anything else as well – here’s an example with classes:
https://tinyurl.com/2t93furt
I’ve also tried to design a semi-declarative DI container – this should look more familiar:
https://tinyurl.com/2p8e9jn2
This approach isn’t fully declarative – you do need to define a service map separately from the actual service definitions. You might actually enjoy this kind of separation, or you may find it overly ceremonious – that seems to be an individual thing.
Both approaches are type-safe, but neither approach is “modular” – meaning you can’t have multiple reusable “providers” or “modules”, and you will have to bootstrap everything in one central location. Again, some will think that’s a good thing, others will find it annoying – it comes down to opinions.
I would love it if somebody would try to develop this idea further. I’ve attempted a number of things myself, and just getting to this point with type-safety took a lot of hard work, and it’s still not clear to me whether this concept can expand to incorporate modules, e.g. with type-checking happening at the call site where you bootstrap your providers/modules into a container.
I don’t know whether the language can do it all.
If it can, I’m afraid I’m not smart enough to beat the type-system. 😅
There is another container without a footprint:
https://itijs.org/
I will try to address all your points, Rasmus 🙂
I believe the general approach (based on my experience alone) to DI in JS/TS should be as follows:
1. If the application doesn’t require direct function calls between different components, don’t use DI. This happens when maintaining separation of concerns is crucial due to application design. This usually means you use message buses in your application to convey information.
2. If your application has a small number of components and each component requires up to 3-4 dependencies, you can still construct the components’ instances it manually in a reasonable number of lines.
3. When the number of components starts growing, you need to start using DI to maintain code readability.
The choice of the DI framework should depend on the specific runtime. For backends, I don’t think anyone minds relying on a footprint from decorators (especially that we chose to use JS/TS there). For frontends, bundle size matters, but bundles can be split and the footprint can be calculated. Also, a frontend application with a lot of components might not work efficiently after all.
Code using DI frameworks with footprint is generally easier to read compared to ones’ without. Basically, if I use DI, I don’t want to construct instances myself, I want the DI to do it for me.
Type safety in TS is a fickle thing. TS is just a superset of JS that compiles code to the latter. There is a a number of problems (e.g. the higher-kinded types) that cannot be expressed in TS code without some “tricks”. A vast number of TS libraries uses type tricks under the hood so we have a notion of type safety in userland code.