Introduction to Dependency Injection in TypeScript
Dependency Injection (DI) is one of the key design patterns that allows you to create loosely coupled, testable, and easily maintainable applications. In the context of TypeScript, thanks to strict typing and the capabilities of decorators, implementing DI becomes especially elegant and safe.
In this article, we will look at what Dependency Injection is, how to implement it in TypeScript, and what tools can simplify this process. You will learn how DI helps fight tight coupling between classes and makes your code more flexible.
What is Dependency Injection and why is it needed?
Dependency Injection is a pattern where an object receives its dependencies from the outside, rather than creating them itself. This implements the principle of Inversion of Control (IoC).
Problem without DI:
class Logger { log(message: string): void { console.log(message); }}
class UserService { private logger: Logger;
constructor() { this.logger = new Logger(); // Tight coupling }
createUser(name: string): void { this.logger.log(`User ${name} created`); }}In this example, UserService is tightly coupled to a specific implementation of Logger. If we want to replace the logger with, say, a file-based or remote one, we would have to change the code of UserService.
Solution with DI:
interface ILogger { log(message: string): void;}
class ConsoleLogger implements ILogger { log(message: string): void { console.log(message); }}
class FileLogger implements ILogger { log(message: string): void { // Writing to file }}
class UserService { constructor(private logger: ILogger) {} // Dependency is injected
createUser(name: string): void { this.logger.log(`User ${name} created`); }}
// Usageconst service = new UserService(new ConsoleLogger());Advantages of DI:
- Testability — easily replace real dependencies with mock objects.
- Flexibility — you can change system behavior without modifying class code.
- Separation of concerns — classes do not handle creating their own dependencies.
- Reusability — dependencies can be used in different parts of the application.
Manual implementation of Dependency Injection in TypeScript
The simplest way is to inject dependencies through the constructor or properties. TypeScript with its interfaces and access modifiers makes this very convenient.
Constructor Injection
interface IRepository<T> { getAll(): Promise<T[]>; getById(id: number): Promise<T | null>;}
class UserRepository implements IRepository<User> { async getAll(): Promise<User[]> { // Real database logic return []; }
async getById(id: number): Promise<User | null> { return null; }}
class UserController { constructor( private repository: IRepository<User>, private logger: ILogger ) {}
async getUsers(): Promise<User[]> { this.logger.log('Fetching users...'); return this.repository.getAll(); }}
// Creating the dependency graphconst logger = new ConsoleLogger();const repository = new UserRepository();const controller = new UserController(repository, logger);Property Injection
class EmailService { private sender!: ISender;
setSender(sender: ISender): void {