Written by Gregory Pabian ✏️
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!
Prerequisites
To follow along with this article, you should be familiar with the following concepts:
- Inversion of Control (IoC): a design pattern that stipulates frameworks should call userland code, instead of userland 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 enable composition and are wrappable around classes, functions, methods, accessors, properties, and parameters
- Decorator metadata: a way to store configuration for language structures in runtime by using decorators to define targets
Explicitly injecting dependencies
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.
Dependency graphs
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.
Dependency injection containers
A DI container requires the following:
- the association of the
ConsoleLogger
class with theLogger
interface - the association of the
LocalFileSystem
class with theFileSystem<string>
interface - the dependency of the
SettingsTxtService
on both theLogger
and theFileSystem<string>
interfaces
Type bindings
Binding a specific type or class to a specific interface in runtime can occur in two ways:
- specifying a name or token that binds the implementation to it
- promoting an interface to an abstract class and allowing the latter to leave a runtime trace
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
Resolving dependencies in runtime is possible in two ways:
- passing all dependencies during object construction
- passing all dependencies using setters and getters after object construction
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:
- using constructor parameter decorators that are capable of calling the DI container's API
- using the DI container's API directly to inform it about the dependencies
Although decorators and metadata, like the Reflect API, are experimental features, they reduce overhead when using DI containers.
Dependency injection container overview
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.
Typed Inject
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.
InversifyJS
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.
TypeDI
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
- token-based injections
Class-based injections
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 in TypeDI
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);
TSyringe
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.
Class-based injections in TSyringe
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);
Token-based injections in TSyringe
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
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);
Summary
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: Full visibility into your web apps
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 apps.
Top comments (0)