typescript DI

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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, 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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);

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

  1. using a constructor parameter decorator that can call the DI container API
  2. 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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 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.

1
2
3
4
5
6
7
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);

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 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.

1
2
3
4
5
6
7
8
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 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@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();
    }
}

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.

1
2
3
4
5
const container = Container.of();

const logger = container.get(TypeDiLogger);
const fileSystem = container.get(TypeDiFileSystem);
const settingsService = container.get(TypeDiSettingsTxtService);

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@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 instances of the classes we need and connect them to the container.

1
2
3
4
5
6
7
8
9
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 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@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 container can then automatically resolve the dependencies.

1
2
3
4
5
const childContainer = container.createChildContainer();

const logger = childContainer.resolve(TsyringeLogger);
const fileSystem = childContainer.resolve(TsyringeFileSystem);
const settingsService = childContainer.resolve(TsyringeSettingsTxtService);

Token-based injection in Tsyringe

Similar to other libraries, TSyringe requires programmers to use constructor parameter decorators for token-based injection.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@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 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.

1
2
3
4
5
6
7
8
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 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@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 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.

1
2
3
4
@Module({
    providers: [NestLogger, NestFileSystem, NestSettingsTxtService],
})
export class AppModule {}

Finally, we can create application contexts and get instances of the above classes.

1
2
3
4
5
6
7
8
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 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.