Dependency Injection in TypeScript: From Theory to Practice
Dependency Injection (DI) is one of the key design patterns that allows you to create flexible, testable, and maintainable applications. In TypeScript, thanks to strong typing and the capabilities of decorators, implementing DI becomes especially elegant. In this article, we will break down how to implement Dependency Injection in practice using TypeScript, and examine why this pattern is a cornerstone of modern development.
What is Dependency Injection and Why Do You Need It?
Dependency Injection is a pattern where an object receives its dependencies from the outside, rather than creating them itself. Instead of writing new SomeService() inside a class, we pass a ready-made instance through the constructor or a method.
Main advantages of DI:
- Reduced Coupling — classes depend not on concrete implementations, but only on abstractions (interfaces).
- Simplified Testing — we can easily replace real services with mocks or stubs.
- Increased Reusability — dependencies can be replaced without changing the consumer's code.
- Adherence to the Dependency Inversion Principle (DIP from SOLID) — high-level modules do not depend on low-level modules.
Implementing Dependency Injection in TypeScript Without Frameworks
Let's start with a simple example. Suppose we have a logging service and a class that uses it.
// Without DI - tight couplingclass Logger { log(message: string): void { console.log(`[LOG]: ${message}`); }}
class UserService { private logger: Logger;
constructor() { this.logger = new Logger(); // Problem: tight dependency }
createUser(name: string): void { this.logger.log(`User created: ${name}`); // ... creation logic }}
// With DI - via constructorinterface ILogger { log(message: string): void;}
class ConsoleLogger implements ILogger { log(message: string): void { console.log(`[CONSOLE LOG]: ${message}`); }}
class FileLogger implements ILogger { log(message: string): void { // Logic for writing to a file console.log(`[FILE LOG]: ${message}`); }}
class UserServiceWithDI { constructor(private logger: ILogger) {} // Dependency is injected from outside
createUser(name: string): void { this.logger.log(`User created: ${name}`); }}
// Usageconst consoleLogger = new ConsoleLogger();const userService = new UserServiceWithDI(consoleLogger);userService.createUser('Alice'); // [CONSOLE LOG]: User created: AliceAs you can see, now UserServiceWithDI does not know which specific logger is being used. We can easily replace it with a FileLogger or a mock for testing.
Advanced DI: Creating a Simple IoC Container
When there are many dependencies, manual injection via the constructor becomes cumbersome. The IoC Container (Inversion of Control Container) comes to the rescue. It manages the creation and lifecycle of objects.
Let's implement a simple container in TypeScript:
// Type for a dependency factorytype Factory<T> = (container: Container) => T;
class Container { private factories = new Map<string, Factory<any>>(); private instances = new Map<string, any>();
// Register a dependency (factory is called on each request) register<T>(name: string, factory: Factory<T>): void { this.factories.set(name, factory); }
// Register a singleton (factory is called once) singleton<T>(name: string, factory: Factory<T>): void { // ... }}