The original JavaScript environment lacked a static type system and had little support for containerized dependency injection, making the code I wrote prone to obvious errors and nearly impossible to test.
TypeScript’s compile-time type system changes all that, allowing continuous development of complex projects. It has brought back design patterns such as dependency injection, proper typing and passing of dependencies during object construction, which promotes more structured programming and helps write tests without the need for patching.
In this article, we’ll review five containerized dependency injection tools for writing dependency injection systems in TypeScript!
Prerequisites
To study this article, you should be familiar with the following concepts.
- Inversion of Control (IoC): a design pattern that states that frameworks should call user-state code instead of user-state code calling library code
- Dependency Injection (DI): a variant of IoC in which objects receive other objects as dependencies instead of constructors or setters
- Decorators: functions that support composition and can be wrapped around classes, functions, methods, accessors, properties, and parameters
- Decorator metadata: a way to store language structure configuration at runtime by using decorator definition targets
Explicitly injecting dependencies
Interfaces allow developers to separate abstract requirements from the actual implementation, which is very helpful when writing tests. Note that interfaces only define functionality, not dependencies. Finally, interfaces do not leave runtime traces, but classes do.
Let’s consider three example interfaces.
|
|
The Logger
interface abstracts synchronous logging, while the generic FileSystem
interface abstracts file CRUD operations. Finally, the SettingsService
interface provides a business logic abstraction for settings management.
We can infer that any implementation of SettingsService
depends on some implementation of the Logger
and FileSystem
interfaces. For example, we could create a ConsoleLogger
class to print logs to console output, a LocalFileSystem
to manage files on the local disk, or a SettingsTxtService
class to write application settings to a file. settings.txt
Dependencies can be passed explicitly using special functions.
|
|
SettingsTxtService
This class does not depend on ConsoleLogger
or a similar implementation of LocalFileSystem
. Instead, it depends on the above interfaces Logger
and . FileSystem<string>
.
However, explicitly managing dependencies can cause problems for every DI container, since the interface does not exist at runtime.
Dependency Graph
Most injectable components of any system depend on other components. You should always be able to graph them, and the graph of a well-thought-out system will be loop-free. In my experience, circular dependencies are a code error, not a pattern.
The more complex the project, the more complex the dependency graph will be. In other words, managing dependencies explicitly does not scale well. We can solve this problem by automating dependency management, which makes it implicit. For this, we need a DI container.
Dependency Injection Container
The DI container requires the following.
ConsoleLogger
class association with theLogger
interface- the association of the
LocalFileSystem
class with the interfaceFileSystem<string>
- Correlation of
SettingsTxtService
on bothLogger
and interfaceFileSystem<string>
Type binding
Binding a specific type or class to a specific interface at runtime can happen in two ways.
- specifying the name or token to bind the implementation to it
- elevating the interface to an abstract class and allowing the latter to leave a runtime trace
For example, we can use the container’s API to explicitly declare ConsoleLogger
that the class is associated with a logger
token. Alternatively, we can use a class-level decorator that accepts the name of the token as its argument. The decorator will then use the container’s API to register the binding.
If the Logger
interface becomes an abstract class, we can apply class-level decorators to it and all its derived classes. When doing so, the decorator will call the container’s API to keep track of the runtime associations.
Resolving Dependencies
Dependencies can be resolved at runtime in two ways.
- Passing all dependencies during object construction
- Passing all dependencies after object construction using setter and getter
We will focus on the first option. the DI container is responsible for instantiating and maintaining the lifecycle of each component. Therefore, the container needs to know where to inject dependencies.
We have two ways to provide this information.
- using a constructor parameter decorator that can call the DI container API
- use the DI container’s API directly to inform it of the dependencies
Although decorators and metadata (such as Reflect API) are experimental features, they reduce the overhead when using the DI containers to reduce overhead.
Dependency Injection Containers Overview
Now, let’s look at five popular dependency injection containers. Note that the order used in this tutorial reflects how DI evolved as a pattern when it was applied to the TypeScript community.
Typed Inject
The focus of the Typed Inject project is type safety and explicitness. It uses neither decorators nor decorator metadata, opting instead to declare dependencies manually. It allows the existence of multiple DI containers and the dependencies are qualified as single instance or transient objects.
The following code snippet outlines the conversion from contextual DI (shown in the previous code snippet) to typed injected DI.
|
|
The TypedInjectLogger
and TypedInjectFileSystem
classes serve as concrete implementations of the required interfaces. Type binding is defined at the class level by listing object dependencies using the inject
static variable.
The following code snippet demonstrates all the major container operations in the Typed Inject environment.
|
|
Use the createInjector
function to instantiate the container and explicitly declare the token-to-class binding. Developers can use this resolve
function to access instances of the provided class. The injectable classes can be obtained using this injectClass
method.
InversifyJS
The InversifyJS project provides a lightweight DI container that creates application interfaces by tokenization. It uses decorators and decorator metadata for injection. However, binding the implementation to the interface still requires some manual work.
Dependency scoping is supported. The scope of an object can be a single instance object or a transient object, or it can be bound to a request. If necessary, developers can use separate DI containers.
The following code snippet demonstrates how to convert a contextual DI interface to use InversifyJS.
|
|
Following the official documentation, I created a mapping called TYPES
that contains all the tokens we will use for injection later on. I implemented the necessary interfaces, and @injectable
added class-level decorators for each interface. The arguments to the InversifySettingsTxtService
constructor use the @injectable
decorator to help the DI container resolve dependencies at runtime.
The code for the DI container is shown in the following code snippet.
|
|
InversifyJS uses a fluent interface pattern. the IoC container implements type binding between tokens and classes by explicitly declaring it in code. Getting an instance of a managed class requires only one call for proper conversion.
TypeDI
The TypeDI project is intended for simplicity by making use of decorated and decorated metadata. It supports single instance and transient object dependency scopes and allows the existence of multiple DI containers. You have two options for using TypeDI.
- Class-based injection
- Token-based injection
Class-based injection
Class-based injection allows classes to be inserted by passing interface class relationships.
|
|
Each class uses the class-level @Service
decorator. The global
option means that all classes will be instantiated globally as a singleton. The class constructor parameter TypeDiSettingsTxtService
explicitly declares that it requires an instance of the TypeDiLogger
class and a TypeDiFileSystem
class.
Once we have declared all the dependencies, we can use the TypeDI container as shown below.
Token-based Injection in TypeDI
Token-based injection uses tokens as intermediaries to bind interfaces to their implementations. The only change over class-based injections is the declaration of the @Inject
decoration that uses the appropriate token for each structural parameter.
|
|
We have to construct instances of the classes we need and connect them to the container.
TSyringe
The TSyringe project is a DI container maintained by Microsoft. It is a versatile container that supports almost all standard DI container features, including resolving circular dependencies. Similar to TypeDI, TSyringe supports both class-based and token-based injection.
Class-based injection in Tsyringe
Developers must use Tsyringe’s class-level decorators to mark target classes. In the following code snippet, we use the @singleton
decorator.
|
|
The Tsyringe container can then automatically resolve the dependencies.
Token-based injection in Tsyringe
Similar to other libraries, TSyringe requires programmers to use constructor parameter decorators for token-based injection.
|
|
After declaring the target class, we can register a token class tuple with an associated lifecycle. In the following code snippet, I am using a singleton case.
|
|
NestJS
NestJS is a framework that uses custom DI containers at the bottom. NestJS can be run as a standalone application as a wrapper for its DI container. It uses decorators and their metadata for injection. Scope is allowed and you can choose from single instance, transient object or request bound object.
The following code snippet includes a demonstration of NestJS functionality, starting with the declaration of the core class.
|
|
In the above code block, all target classes are marked with @Injectable
decorators. Next, we define the core class of the AppModule
application and specify its dependencies providers
.
Finally, we can create application contexts and get instances of the above classes.
|
|
Summary
In this tutorial, we introduced what a dependency injection container is and why you should use it. We then explored five different TypeScript dependency injection containers and walked through examples of how to use each one.
Now that TypeScript is a dominant programming language, using established design patterns such as dependency injection can help developers make the transition from other languages.