Building a Decorator-Driven Dependency Injection Container in TypeScript
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Introduction
Modern web applications, especially those built with frameworks like Angular or NestJS, heavily rely on modularity and testability. A crucial pattern enabling these qualities is Dependency Injection (DI). While frameworks often provide sophisticated DI systems out-of-the-box, understanding and implementing a custom one, particularly with the power of TypeScript decorators, can be incredibly insightful. It offers a deeper understanding of how these systems work and provides fine-grained control when building smaller, more specialized applications or libraries. This article will guide you through the process of creating a simple yet powerful automated dependency injection container using TypeScript decorators, demonstrating how to achieve a clean, maintainable, and testable codebase.
Understanding the Core Concepts
Before we dive into the implementation, let's establish a common understanding of the key terms involved:
- Dependency Injection (DI): A design pattern where components receive their dependencies from an external source rather than creating them themselves. This promotes loose coupling, making components more independent, reusable, and testable.
- IoC Container (Inversion of Control Container): Often synonymous with a DI container, it's a framework or library that manages the instantiation and lifecycle of objects within an application. It "inverts" the control of object creation from the components themselves to the container.
- Decorator: A special kind of declaration that can be attached to a class declaration, method, accessor, property, or parameter. Decorators use the form
@expression
, whereexpression
must evaluate to a function that will be called at runtime with information about the decorated declaration. In TypeScript, they offer a powerful way to add metadata or modify the behavior of classes and their members declaratively. - Service/Injectable: A class or object that performs a specific task and can be injected into other components. In the context of DI, these are the components whose instances the container will manage.
- Provider: A mechanism to tell the DI container how to create an instance of a particular dependency. This could be a class, a value, or a factory function.
The fundamental principle behind our decorator-driven DI container is to use decorators to mark classes as injectable and to identify their constructor dependencies. The container will then use this metadata at runtime to resolve and instantiate the required services automatically.
Implementing the Dependency Injection Container
Our DI container will consist of a few key components: a decorator to mark injectable classes, a decorator to specify how to inject dependencies, and the container itself responsible for managing instances.
1. The @Injectable
Decorator
This decorator will serve two purposes: first, to mark a class as a service that our container can manage, and second, to store metadata about its dependencies.
// reflect-metadata is a required polyfill for decorators to work with type information import 'reflect-metadata'; // A symbol to store constructor parameter types const INJECT_METADATA_KEY = Symbol('design:paramtypes'); /** * Marks a class as injectable by the DI container. * This decorator stores metadata about the class's constructor parameters, * allowing the container to resolve its dependencies. * @returns A class decorator */ function Injectable(): ClassDecorator { return (target: Function) => { // No explicit action needed here for now, as TypeScript's // emitDecoratorMetadata handles storing design:paramtypes // for constructor parameters. We just need to ensure it's run. }; }
Explanation: The Injectable
decorator itself doesn't do much directly in its body. Its primary role is to trigger TypeScript's emitDecoratorMetadata
feature (which must be enabled in tsconfig.json
). When emitDecoratorMetadata
is true, TypeScript automatically emits metadata about the types of parameters of a class's constructor and stores it using the design:paramtypes
key (a well-known symbol) via the reflect-metadata
polyfill. Our container will later read this metadata.
2. The Container Core
This is where the magic happens – the Container
class will manage our services.
import 'reflect-metadata'; // Ensure this is imported once globally type Constructor<T> = new (...args: any[]) => T; /** * A simple Dependency Injection Container for managing service instances. */ class Container { private static instance: Container; private readonly providers = new Map<Constructor<any>, any>(); // Stores singleton instances or factory functions private constructor() {} /** * Gets the singleton instance of the Container. */ public static getInstance(): Container { if (!Container.instance) { Container.instance = new Container(); } return Container.instance; } /** * Registers a class as a provider in the container. * By default, it registers as a singleton. * @param target The class constructor to register. */ public register<T>(target: Constructor<T>): void { if (this.providers.has(target)) { console.warn(`Service ${target.name} is already registered.`); return; } // At this point, we just register the constructor itself. // The instance will be created upon first resolution. this.providers.set(target, target); } /** * Resolves an instance of the given class from the container. * It handles recursively resolving dependencies. * @param target The class constructor to resolve. * @returns An instance of the requested class. */ public resolve<T>(target: Constructor<T>): T { // If an instance is already created (singleton), return it. // For simplicity, we'll implement a basic singleton pattern directly here. // More advanced containers might have explicit lifecycle management flags. if (this.providers.has(target) && (this.providers.get(target) instanceof target)) { return this.providers.get(target); } // Get constructor parameter types (dependencies) using reflect-metadata const paramTypes: Constructor<any>[] = Reflect.getMetadata('design:paramtypes', target) || []; const dependencies = paramTypes.map(paramType => { if (!paramType) { // This can happen if a primitive type is injected or if emitDecoratorMetadata is off throw new Error(`Cannot resolve dependency for ${target.name}. ` + `Ensure 'emitDecoratorMetadata' is true in tsconfig.json ` + `and all dependencies are also @Injectable.`); } // Recursively resolve dependencies return this.resolve(paramType); }); // Create a new instance of the target class with its resolved dependencies const instance = new target(...dependencies); // Store the newly created singleton instance this.providers.set(target, instance); return instance; } } // Export a convenience instance const container = Container.getInstance(); export { container, Injectable };
Explanation:
Container.getInstance()
: Implements the singleton pattern for our container. We only need one central instance to manage all services.register(target: Constructor<T>)
: This method allows you to explicitly register classes with the container. While ourresolve
method can implicitly find dependencies, registering them upfront can be useful for explicit configuration. For this basic example,register
simply stores the constructor itself, and the actual instance is created on firstresolve
.resolve(target: Constructor<T>): T
: This is the core of the DI container.- It first checks if an instance of
target
already exists (implementing a basic singleton behavior). - It then uses
Reflect.getMetadata('design:paramtypes', target)
to retrieve the types of the constructor parameters. This is where thereflect-metadata
polyfill andemitDecoratorMetadata
come into play. - It recursively calls
this.resolve()
for each parameter type to obtain instances of the dependencies. - Finally, it instantiates the
target
class using thenew
operator and the resolved dependencies as constructor arguments. - The newly created instance is then stored in
providers
for subsequent requests, acting as a singleton.
- It first checks if an instance of
3. Usage Example
Let's illustrate how to use our container with some example services.
// services.ts import { container, Injectable } from './container'; // Assuming container.ts is in the same directory @Injectable() class LoggerService { log(message: string): void { console.log(`[Logger]: ${message}`); } } @Injectable() class DataService { constructor(private logger: LoggerService) {} // LoggerService is a dependency getData(): string { this.logger.log('Fetching data...'); return 'Hello from DataService!'; } } @Injectable() class ApplicationService { constructor(private dataService: DataService, private logger: LoggerService) {} // DataService and LoggerService are dependencies run(): void { this.logger.log('Application starting...'); const data = this.dataService.getData(); this.logger.log(`Received data: ${data}`); this.logger.log('Application finished.'); } } // main.ts import { container } from './container'; import { ApplicationService } from './services'; // Register all services with the container (optional, but good practice for clarity) // In a larger app, you might have a module system or automatic discovery container.register(LoggerService); container.register(DataService); container.register(ApplicationService); // Resolve the top-level application service const app = container.resolve(ApplicationService); app.run(); // Verify singleton behavior const anotherLogger = container.resolve(LoggerService); const firstLogger = container.resolve(LoggerService); console.log('Are loggers the same instance?', anotherLogger === firstLogger); // Should be true
To run this code, ensure you have:
- Installed
reflect-metadata
:npm install reflect-metadata
- Enabled
emitDecoratorMetadata
andexperimentalDecorators
in yourtsconfig.json
:{ "compilerOptions": { "target": "es2016", "module": "commonjs", "emitDecoratorMetadata": true, "experimentalDecorators": true, "outDir": "./dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true } }
- Compiled your TypeScript:
tsc
- Run the compiled JavaScript:
node dist/main.js
You should see output similar to this:
[Logger]: Application starting...
[Logger]: Fetching data...
[Logger]: Received data: Hello from DataService!
[Logger]: Application finished.
Are loggers the same instance? true
This demonstrates how our ApplicationService
automatically receives instances of DataService
and LoggerService
in its constructor, without explicitly creating them. Furthermore, DataService
also receives LoggerService
. All services are managed as singletons by our container.
Application Scenarios
- Microservices and API Gateways: Easily manage and inject service clients, authentication providers, and common utility services across different microservices.
- Command-Line Tools: Construct complex CLI applications where different commands or modules require specific configurations or helper classes.
- Building Custom Frameworks/Libraries: Provide a lightweight DI solution for your own libraries, allowing consumers to extend and integrate components more easily.
- Testability: The most significant benefit. Because components receive their dependencies, you can easily mock or replace these dependencies during unit testing, leading to isolated and efficient tests.
Conclusion
We've successfully built a basic yet functional dependency injection container in TypeScript, leveraging the power of decorators. By marking classes with @Injectable
and letting the container handle parameter resolution, we achieve a highly modular, loosely coupled, and testable codebase. This approach significantly enhances code organization and maintainability, allowing for cleaner application architectures. The ability to automatically manage dependencies is an indispensable asset for modern JavaScript development.