DEV Community

loading...
Cover image for Top 5 TypeScript dependency injection containers
LogRocket

Top 5 TypeScript dependency injection containers

Matt Angelosanto
Managing editor for the LogRocket blog. I didn't write the post you just read. To find out who did, click the link directly below my name.
Originally published at blog.logrocket.com ・9 min read

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>;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 the Logger interface
  • the association of the LocalFileSystem class with the FileSystem<string> interface
  • the dependency of the SettingsTxtService on both the Logger and the FileSystem<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:

  1. using constructor parameter decorators that are capable of calling the DI container's API
  2. 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>,
    ) {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 Dashboard Free Trial Banner

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.

Try it for free.

Discussion (0)