Dependency Injection Explained for Beginners in TypeScript

Master dependency injection in TypeScript with practical examples. Learn DI patterns, constructor injection, interface-based DI, IoC containers, and build testable, maintainable applications

πŸ“… Published: July 14, 2025 ✏️ Updated: August 3, 2025 By Ojaswi Athghara
#dependency #injection #web-development #oop #testing

Dependency Injection Explained for Beginners in TypeScript

My Tests Failed Because I Couldn't Mock a Database

I was building a user registration system:

class UserService {
  createUser(userData: UserData) {
    const db = new Database();  // Created inside!
    const email = new EmailService();  // Created inside!
    
    db.save(userData);
    email.send(userData.email, "Welcome!");
  }
}

Everything worked in production. Then I tried to write tests:

test("createUser saves to database", () => {
  const service = new UserService();
  // How do I test without real database? 😱
  // How do I mock EmailService? 😱
});

Impossible. My code was tightly coupled to real implementations.

My tech lead said: "You need dependency injection. Let me show you."

That one conversation transformed my testing and design skills. Today, I'll teach you dependency injection with practical TypeScript examples.

What Is Dependency Injection?

Dependency Injection (DI) is a design pattern where objects receive their dependencies from the outside instead of creating them internally.

❌ Without DI (Tight Coupling)

class UserService {
  private database: Database;
  
  constructor() {
    this.database = new Database();  // Creates its own dependency
  }
}

βœ… With DI (Loose Coupling)

class UserService {
  constructor(private database: Database) {  // Receives dependency
    // Database is injected!
  }
}

const db = new Database();
const service = new UserService(db);

Key difference: With DI, dependencies flow in from the outside.

Why Use Dependency Injection?

1. Testability - Easy to Mock Dependencies

// βœ… Easy to test with DI
class UserService {
  constructor(private database: IDatabase) {}
  
  async createUser(data: UserData): Promise<void> {
    await this.database.save(data);
  }
}

// Test with mock database
test("createUser saves to database", async () => {
  const mockDb = { save: jest.fn() };
  const service = new UserService(mockDb);
  
  await service.createUser({ name: "Alice" });
  
  expect(mockDb.save).toHaveBeenCalledWith({ name: "Alice" });
});

2. Flexibility - Easy to Swap Implementations

const prodService = new UserService(new PostgreSQLDatabase());
const testService = new UserService(new InMemoryDatabase());

3. Maintainability - Loose Coupling

Changes to dependencies don't require changing dependent classes.

DI Pattern 1: Constructor Injection

Most common pattern. Dependencies are passed through the constructor.

interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
}

class FileLogger implements Logger {
  log(message: string): void {
    console.log(`[FILE] Writing to log file: ${message}`);
  }
}

class UserService {
  constructor(private logger: Logger) {}
  
  createUser(name: string): void {
    this.logger.log(`Creating user: ${name}`);
    // User creation logic
  }
}

// Easy to switch logger implementations!
const consoleService = new UserService(new ConsoleLogger());
const fileService = new UserService(new FileLogger());

consoleService.createUser("Alice");  // [LOG] Creating user: Alice
fileService.createUser("Bob");       // [FILE] Writing to log file: Creating user: Bob

Benefits:

  • βœ… Dependencies are explicit
  • βœ… Immutable after construction
  • βœ… Easy to test

DI Pattern 2: Multiple Dependencies

interface IDatabase {
  save(data: any): Promise<void>;
  find(id: string): Promise<any>;
}

interface IEmailService {
  send(to: string, subject: string, body: string): Promise<void>;
}

interface ILogger {
  log(message: string): void;
  error(message: string): void;
}

class UserService {
  constructor(
    private database: IDatabase,
    private emailService: IEmailService,
    private logger: ILogger
  ) {}
  
  async registerUser(userData: { name: string; email: string }): Promise<void> {
    try {
      this.logger.log(`Registering user: ${userData.name}`);
      
      await this.database.save(userData);
      this.logger.log("User saved to database");
      
      await this.emailService.send(
        userData.email,
        "Welcome!",
        `Hello ${userData.name}, welcome to our platform!`
      );
      this.logger.log("Welcome email sent");
      
    } catch (error) {
      this.logger.error(`Registration failed: ${error.message}`);
      throw error;
    }
  }
}

// Implementations
class PostgreSQLDatabase implements IDatabase {
  async save(data: any): Promise<void> {
    console.log("πŸ’Ύ Saving to PostgreSQL:", data);
  }
  
  async find(id: string): Promise<any> {
    console.log("πŸ” Finding in PostgreSQL:", id);
    return {};
  }
}

class SendGridEmailService implements IEmailService {
  async send(to: string, subject: string, body: string): Promise<void> {
    console.log(`πŸ“§ Sending email via SendGrid to ${to}`);
    console.log(`   Subject: ${subject}`);
  }
}

class ConsoleLogger implements ILogger {
  log(message: string): void {
    console.log(`[LOG] ${message}`);
  }
  
  error(message: string): void {
    console.error(`[ERROR] ${message}`);
  }
}

// Wire up dependencies
const database = new PostgreSQLDatabase();
const emailService = new SendGridEmailService();
const logger = new ConsoleLogger();

const userService = new UserService(database, emailService, logger);

await userService.registerUser({ 
  name: "Alice", 
  email: "alice@example.com" 
});

DI Pattern 3: Interface-Based DI

Using interfaces makes testing and swapping implementations easy:

// Interfaces (contracts)
interface PaymentGateway {
  processPayment(amount: number, cardToken: string): Promise<boolean>;
}

interface NotificationService {
  notify(userId: string, message: string): Promise<void>;
}

interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
}

// Domain model
interface Order {
  id: string;
  userId: string;
  amount: number;
  status: string;
}

// Service with injected dependencies
class OrderService {
  constructor(
    private paymentGateway: PaymentGateway,
    private notificationService: NotificationService,
    private orderRepository: OrderRepository
  ) {}
  
  async placeOrder(
    userId: string, 
    amount: number, 
    cardToken: string
  ): Promise<Order> {
    const order: Order = {
      id: Math.random().toString(36).substr(2, 9),
      userId,
      amount,
      status: "pending"
    };
    
    const paymentSuccess = await this.paymentGateway.processPayment(
      amount, 
      cardToken
    );
    
    if (paymentSuccess) {
      order.status = "confirmed";
      await this.orderRepository.save(order);
      await this.notificationService.notify(
        userId, 
        `Order ${order.id} confirmed!`
      );
    } else {
      order.status = "failed";
    }
    
    return order;
  }
}

// Production implementations
class StripePaymentGateway implements PaymentGateway {
  async processPayment(amount: number, cardToken: string): Promise<boolean> {
    console.log(`πŸ’³ Processing $${amount} via Stripe...`);
    // Stripe API call
    return true;
  }
}

class PushNotificationService implements NotificationService {
  async notify(userId: string, message: string): Promise<void> {
    console.log(`πŸ”” Sending push notification to user ${userId}: ${message}`);
  }
}

class MongoOrderRepository implements OrderRepository {
  async save(order: Order): Promise<void> {
    console.log("πŸ’Ύ Saving order to MongoDB:", order);
  }
  
  async findById(id: string): Promise<Order | null> {
    console.log("πŸ” Finding order in MongoDB:", id);
    return null;
  }
}

// Production setup
const orderService = new OrderService(
  new StripePaymentGateway(),
  new PushNotificationService(),
  new MongoOrderRepository()
);

await orderService.placeOrder("user123", 99.99, "tok_visa");


// Test setup (with mocks)
class MockPaymentGateway implements PaymentGateway {
  async processPayment(amount: number, cardToken: string): Promise<boolean> {
    return true;  // Always succeeds in tests
  }
}

class MockNotificationService implements NotificationService {
  notifications: Array<{ userId: string; message: string }> = [];
  
  async notify(userId: string, message: string): Promise<void> {
    this.notifications.push({ userId, message });
  }
}

class InMemoryOrderRepository implements OrderRepository {
  orders: Order[] = [];
  
  async save(order: Order): Promise<void> {
    this.orders.push(order);
  }
  
  async findById(id: string): Promise<Order | null> {
    return this.orders.find(o => o.id === id) || null;
  }
}

// Test
const mockPayment = new MockPaymentGateway();
const mockNotification = new MockNotificationService();
const mockRepository = new InMemoryOrderRepository();

const testOrderService = new OrderService(
  mockPayment,
  mockNotification,
  mockRepository
);

const order = await testOrderService.placeOrder("test-user", 50.00, "tok_test");

console.log("\n=== Test Results ===");
console.log("Order saved:", mockRepository.orders.length > 0);
console.log("Notifications sent:", mockNotification.notifications.length);
console.log("Order status:", order.status);

DI Container (Simple Implementation)

For larger applications, use a DI container to manage dependencies:

type Constructor<T = any> = new (...args: any[]) => T;

class DIContainer {
  private services: Map<string, any> = new Map();
  private singletons: Map<string, any> = new Map();
  
  // Register a singleton (created once, reused)
  registerSingleton<T>(name: string, constructor: Constructor<T>): void {
    this.services.set(name, { constructor, singleton: true });
  }
  
  // Register a transient (new instance each time)
  registerTransient<T>(name: string, constructor: Constructor<T>): void {
    this.services.set(name, { constructor, singleton: false });
  }
  
  // Register an instance directly
  registerInstance<T>(name: string, instance: T): void {
    this.singletons.set(name, instance);
  }
  
  // Resolve a service
  resolve<T>(name: string): T {
    // Check if already instantiated as singleton
    if (this.singletons.has(name)) {
      return this.singletons.get(name);
    }
    
    const service = this.services.get(name);
    
    if (!service) {
      throw new Error(`Service '${name}' not registered`);
    }
    
    const instance = new service.constructor();
    
    if (service.singleton) {
      this.singletons.set(name, instance);
    }
    
    return instance;
  }
}

// Usage
const container = new DIContainer();

// Register services
container.registerSingleton("logger", ConsoleLogger);
container.registerSingleton("database", PostgreSQLDatabase);
container.registerTransient("emailService", SendGridEmailService);

// Resolve services
const logger = container.resolve<ILogger>("logger");
const database = container.resolve<IDatabase>("database");
const emailService = container.resolve<IEmailService>("emailService");

// Use in application
const userService = new UserService(database, emailService, logger);

Real-World Example: Blog Platform

// Domain models
interface Post {
  id: string;
  title: string;
  content: string;
  authorId: string;
  createdAt: Date;
}

interface Comment {
  id: string;
  postId: string;
  authorId: string;
  content: string;
  createdAt: Date;
}

// Interfaces
interface IPostRepository {
  save(post: Post): Promise<void>;
  findById(id: string): Promise<Post | null>;
  findAll(): Promise<Post[]>;
}

interface ICommentRepository {
  save(comment: Comment): Promise<void>;
  findByPostId(postId: string): Promise<Comment[]>;
}

interface ICache {
  get(key: string): Promise<any | null>;
  set(key: string, value: any, ttl: number): Promise<void>;
}

interface ISearchIndex {
  indexPost(post: Post): Promise<void>;
  search(query: string): Promise<Post[]>;
}

// Blog service with multiple dependencies
class BlogService {
  constructor(
    private postRepo: IPostRepository,
    private commentRepo: ICommentRepository,
    private cache: ICache,
    private searchIndex: ISearchIndex,
    private logger: ILogger
  ) {}
  
  async createPost(
    title: string,
    content: string,
    authorId: string
  ): Promise<Post> {
    this.logger.log(`Creating post: ${title}`);
    
    const post: Post = {
      id: Math.random().toString(36).substr(2, 9),
      title,
      content,
      authorId,
      createdAt: new Date()
    };
    
    await this.postRepo.save(post);
    await this.searchIndex.indexPost(post);
    
    // Invalidate cache
    await this.cache.set(`post:${post.id}`, post, 3600);
    
    this.logger.log(`Post created: ${post.id}`);
    return post;
  }
  
  async getPostWithComments(postId: string): Promise<{
    post: Post;
    comments: Comment[];
  } | null> {
    // Check cache first
    const cached = await this.cache.get(`post-with-comments:${postId}`);
    if (cached) {
      this.logger.log(`Cache hit for post: ${postId}`);
      return cached;
    }
    
    this.logger.log(`Cache miss for post: ${postId}`);
    
    const post = await this.postRepo.findById(postId);
    if (!post) return null;
    
    const comments = await this.commentRepo.findByPostId(postId);
    
    const result = { post, comments };
    
    // Cache for 10 minutes
    await this.cache.set(`post-with-comments:${postId}`, result, 600);
    
    return result;
  }
  
  async searchPosts(query: string): Promise<Post[]> {
    this.logger.log(`Searching for: ${query}`);
    return await this.searchIndex.search(query);
  }
}

// Implementations
class MongoPostRepository implements IPostRepository {
  async save(post: Post): Promise<void> {
    console.log("πŸ’Ύ Saving post to MongoDB");
  }
  
  async findById(id: string): Promise<Post | null> {
    console.log(`πŸ” Finding post ${id} in MongoDB`);
    return null;
  }
  
  async findAll(): Promise<Post[]> {
    console.log("πŸ“„ Getting all posts from MongoDB");
    return [];
  }
}

class MongoCommentRepository implements ICommentRepository {
  async save(comment: Comment): Promise<void> {
    console.log("πŸ’Ύ Saving comment to MongoDB");
  }
  
  async findByPostId(postId: string): Promise<Comment[]> {
    console.log(`πŸ” Finding comments for post ${postId}`);
    return [];
  }
}

class RedisCache implements ICache {
  async get(key: string): Promise<any | null> {
    console.log(`πŸ” Redis GET: ${key}`);
    return null;
  }
  
  async set(key: string, value: any, ttl: number): Promise<void> {
    console.log(`πŸ’Ύ Redis SET: ${key} (TTL: ${ttl}s)`);
  }
}

class ElasticsearchIndex implements ISearchIndex {
  async indexPost(post: Post): Promise<void> {
    console.log(`πŸ“‡ Indexing post: ${post.title}`);
  }
  
  async search(query: string): Promise<Post[]> {
    console.log(`πŸ” Searching Elasticsearch: ${query}`);
    return [];
  }
}

// Wire up all dependencies
const blogService = new BlogService(
  new MongoPostRepository(),
  new MongoCommentRepository(),
  new RedisCache(),
  new ElasticsearchIndex(),
  new ConsoleLogger()
);

// Use the service
await blogService.createPost(
  "Understanding Dependency Injection",
  "DI is a powerful pattern...",
  "author123"
);

Benefits Summary

1. Testability

// Easy to test with mocks
const service = new UserService(mockDb, mockEmail, mockLogger);

2. Flexibility

// Easy to swap implementations
const prodService = new UserService(realDb, realEmail, realLogger);
const devService = new UserService(fakeDb, fakeEmail, fakeLogger);

3. Loose Coupling

// Service doesn't know about concrete implementations
class Service {
  constructor(private dep: IInterface) {}
}

4. Single Responsibility

// Service focuses on business logic
// Dependencies handle technical details

Common Mistakes

❌ Mistake 1: Service Locator Anti-Pattern

// BAD: Service locator (anti-pattern)
class UserService {
  createUser() {
    const db = ServiceLocator.get("database");  // Hidden dependency!
    db.save();
  }
}

❌ Mistake 2: Too Many Dependencies

// BAD: God object
class Service {
  constructor(
    dep1, dep2, dep3, dep4, dep5, 
    dep6, dep7, dep8, dep9, dep10
  ) {}  // Too many dependencies!
}

// GOOD: Split into smaller services

❌ Mistake 3: Depending on Concrete Classes

// BAD: Tight coupling
constructor(private db: PostgreSQLDatabase) {}

// GOOD: Depend on interface
constructor(private db: IDatabase) {}

Conclusion: DI Makes Code Better

Key Takeaways:

  • βœ… Inject dependencies through constructor
  • βœ… Depend on interfaces, not implementations
  • βœ… Use DI for testability and flexibility
  • βœ… Keep dependencies explicit
  • βœ… Consider DI containers for large apps

Remember: Dependency Injection isn't about frameworksβ€”it's a design principle that makes your code testable, flexible, and maintainable.

Start using DI in your TypeScript projects today!


Building testable TypeScript applications with DI? I'd love to hear about your experience! Connect with me on Twitter or LinkedIn!

Support My Work

If this guide helped you understand dependency injection and build better TypeScript applications, I'd really appreciate your support, I'd really appreciate your support! Creating comprehensive, free content like this takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources for developers.

β˜• Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!


Cover image by Louis Reed on Unsplash

Related Blogs

Ojaswi Athghara

SDE, 4+ Years

Β© ojaswiat.com 2025-2027