Gregory Pabian Full-stack software developer who loves building products.

Top 5 TypeScript dependency injection containers

7 min read 2182

Typescript Dependency Injection Containers

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 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();
    }
}

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.


More great articles from LogRocket:


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.

: Full visibility into your web and mobile 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 and mobile apps.

.
Gregory Pabian Full-stack software developer who loves building products.

5 Replies to “Top 5 TypeScript dependency injection containers”

  1. 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.

    1. 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.

  2. 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. 😅

    1. 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.

Leave a Reply