Right, so you’ve decided to build something that doesn’t turn into a tangled mess of new keywords and import spaghetti the moment it scales past “Hello, World.” Good. You’re in the right place. Dependency Injection (DI) is the practice of handing your classes their dependencies rather than letting them go out and construct those dependencies themselves. It’s the difference between a teenager rummaging through your fridge and you handing them a prepared plate of food. The outcome is the same (the food is eaten), but one method is chaotic and the other is controlled. A DI Container is just the fancy automated kitchen that prepares all the plates.

We use a container to avoid the manual, soul-crushing work of wiring up dozens of dependencies by hand. It’s a registry: you tell it “when someone asks for a DatabaseConnection, give them this specific instance,” and then it handles the rest, including creating any dependencies that class needs, and so on, recursively. The result is that your application’s composition root (usually main.ts or app.ts) is the only place that looks messy. The rest of your code stays clean, testable, and frankly, sane.

The Absolute Basics: A Manual Container

Let’s not jump straight to a framework. The best way to understand the magic is to build a tragically simple, almost useless version of it yourself. Behold, the world’s most naive DI container:

class Container {
  private registry = new Map<string, any>();

  register<T>(identifier: string, factory: () => T): void {
    this.registry.set(identifier, factory);
  }

  resolve<T>(identifier: string): T {
    const factory = this.registry.get(identifier);
    if (!factory) {
      throw new Error(`Nothing registered for ${identifier}`);
    }
    return factory();
  }
}

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

class EmailService {
  constructor(private logger: Logger) {}
  sendEmail(to: string) {
    this.logger.log(`Sending email to ${to}`);
    // ... actual sending logic
  }
}

// Now, set it all up
const container = new Container();
container.register<Logger>('Logger', () => new ConsoleLogger());
container.register<EmailService>('EmailService', () => {
  const logger = container.resolve<Logger>('Logger');
  return new EmailService(logger);
});

// And use it!
const emailService = container.resolve<EmailService>('EmailService');
emailService.sendEmail('test@example.com');

See what we did there? The EmailService never has to know how to create a Logger. It just declares it needs one. The container’s job is to fulfill that need. This is laughably primitive, but the core concept is all there. The problems are also immediately obvious: using string keys is a one-way ticket to Typo City, population: you, debugging at 2 AM.

Leveling Up: Type-Safe Registration with Reflection

The goal is to move from fragile strings to something the TypeScript compiler can actually reason about. This is where we lean on the type system and, often, a bit of decorator magic or metadata reflection. Here’s a more type-safe approach using classes as our keys:

class BetterContainer {
  private registry = new Map<Function, () => any>();

  register<T>(token: Function, factory: () => T): void {
    this.registry.set(token, factory);
  }

  resolve<T>(token: new (...args: any[]) => T): T {
    const factory = this.registry.get(token);
    if (!factory) {
      throw new Error(`No implementation registered for ${token.name}`);
    }
    return factory();
  }
}

// Usage becomes much cleaner
const betterContainer = new BetterContainer();
betterContainer.register(Logger, () => new ConsoleLogger());
betterContainer.register(EmailService, () => {
  const logger = betterContainer.resolve(Logger); // Look, Ma, no strings!
  return new EmailService(logger);
});

const betterEmailService = betterContainer.resolve(EmailService);

This is a massive improvement. We’re using the class constructor itself as the unique token. Now if you misspell Logger, the TypeScript compiler will scream at you, which is exactly what we want. Our container, however, is still manual. We’re telling it exactly how to create each service. For a real application, this would get tedious fast.

The Magic of Auto-Wiring

This is where the real value lies. An auto-wiring container uses the type information of a class’s constructor parameters to automatically resolve and inject its dependencies. No manual factory functions for most cases. To do this in TypeScript, we need to enable emitDecoratorMetadata and experimentalDecorators in your tsconfig.json and use a library that can read this metadata, like reflect-metadata.

import 'reflect-metadata';

class AutoWiringContainer {
  private registry = new Map<Function, any>();

  register<T>(token: Function, implementation: new (...args: any[]) => T): void {
    this.registry.set(token, implementation);
  }

  resolve<T>(token: new (...args: any[]) => T): T {
    const implementation = this.registry.get(token) || token;
    const paramTypes: any[] = Reflect.getMetadata('design:paramtypes', implementation) || [];
    const dependencies = paramTypes.map((depToken) => this.resolve(depToken));
    return new implementation(...dependencies);
  }
}

// Our services remain blissfully unaware
class DatabaseService {
  connect() { console.log('Connecting to DB...'); }
}

class UserRepository {
  constructor(private database: DatabaseService) {}
  getUsers() {
    this.database.connect();
    return ['Alice', 'Bob'];
  }
}

// Setup is now gloriously simple
const autoContainer = new AutoWiringContainer();
autoContainer.register(DatabaseService, DatabaseService); // Map interface to impl if needed
autoContainer.register(UserRepository, UserRepository);

// The container figures out that UserRepository needs a DatabaseService.
const userRepo = autoContainer.resolve(UserRepository);
console.log(userRepo.getUsers()); // ['Alice', 'Bob']

Now this is power. You just tell the container “here are all the classes I use” and it works out the dependency graph for you. The Reflect.getMetadata('design:paramtypes', ...) call is the secret sauce that reads the types from the TypeScript-compiled metadata.

Common Pitfalls and The Gotchas

This isn’t all rainbows and unicorns. The auto-wiring magic has limits.

  1. Circular Dependencies: ClassA needs ClassB which needs ClassA. The container will try to create A, then B, then A again, and eventually crash with a stack overflow. This is a design smell. Fix it by breaking the cycle, often by introducing an interface or using a lazy factory for one of the dependencies.
  2. Interfaces and Primitive Types: The reflection metadata only works for class types. It can’t tell you that a constructor parameter is of type string or IMyInterface (which evaporates at runtime). For these, you need to use more advanced patterns like token-based registration (Symbol('MyInterface')) or decorators like @inject('config') to provide hints to the container.
  3. Scopes: The simple container above creates a new instance every time you resolve. This is a “transient” scope. For things like database connections, you usually want a “singleton” – one instance shared across the entire app. A production-ready container lets you control this scope during registration.

The key takeaway? A DI container is your ally in writing decoupled, testable code. You can start simple and scale the complexity of your container as your application needs it. Just remember, the goal isn’t to use the most powerful container, but to use the simplest one that gets the job done.