Dependency Injection in TypeScript: A Practical Guide with Examples

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

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: Alice


As 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 { // ... }}

Blogs

Book Recommendations