Skip to content
TypeScript ts patterns 4 min read

Dependency Injection Pattern

Dependency Injection (DI) is the practice of giving an object the collaborators it needs from the outside, rather than letting it construct them itself. Combined with programming to interfaces, DI inverts control: high-level code declares what it depends on, and the wiring layer decides which concrete implementation to supply. The payoff is loose coupling, trivial substitution of fakes in tests, and a single place where the object graph is assembled. TypeScript makes DI especially natural because interfaces describe dependencies precisely and the compiler verifies every wire-up. This page covers constructor injection, a tiny container, and the decorator-based style frameworks use.

The problem it solves

When a class creates its own dependencies with new, it is permanently bound to those concrete classes. You cannot swap the implementation, and tests are forced to use the real thing — a live SMTP server, a real clock, a production database.

// ❌ NotificationService is welded to a concrete email client
class NotificationService {
  private readonly mailer = new SmtpMailer("smtp.example.com");
  send(to: string, body: string) {
    this.mailer.deliver(to, body); // can't test without a real SMTP server
  }
}

DI removes the new from inside the class and hands the dependency in from outside.

Constructor injection against interfaces

The cleanest form passes dependencies through the constructor, typed as interfaces. The class now depends on a capability, not a concrete provider.

interface Mailer {
  deliver(to: string, body: string): Promise<void>;
}

class NotificationService {
  constructor(private readonly mailer: Mailer) {}

  async send(to: string, body: string): Promise<void> {
    await this.mailer.deliver(to, body);
  }
}

// Production
const service = new NotificationService(new SmtpMailer("smtp.example.com"));

// Test — a fake that records calls, no network needed
const sent: string[] = [];
const fakeMailer: Mailer = { async deliver(to) { sent.push(to); } };
const underTest = new NotificationService(fakeMailer);

NotificationService is now agnostic about how mail is sent. The private readonly parameter property both stores and seals the dependency in one line, and the test swaps in a fake without any framework.

A minimal type-safe container

For larger graphs, a container centralizes construction so you wire dependencies once. A small typed registry keeps resolution checked at compile time.

type Factory<T> = (c: Container) => T;

class Container {
  private readonly factories = new Map<symbol, Factory<unknown>>();
  private readonly cache = new Map<symbol, unknown>();

  register<T>(token: symbol, factory: Factory<T>): void {
    this.factories.set(token, factory as Factory<unknown>);
  }

  resolve<T>(token: symbol): T {
    if (this.cache.has(token)) return this.cache.get(token) as T;
    const factory = this.factories.get(token);
    if (!factory) throw new Error(`No provider for ${String(token)}`);
    const value = factory(this) as T;
    this.cache.set(token, value);
    return value;
  }
}

const MAILER = Symbol("Mailer");
const NOTIFIER = Symbol("Notifier");

const container = new Container();
container.register<Mailer>(MAILER, () => new SmtpMailer("smtp.example.com"));
container.register(NOTIFIER, (c) => new NotificationService(c.resolve<Mailer>(MAILER)));

const notifier = container.resolve<NotificationService>(NOTIFIER);

Each token is a symbol, which guarantees uniqueness, and the cache makes resolved services singletons within the container. Swapping MAILER for a stub in tests re-wires the whole graph from one line.

The decorator-based style

Frameworks like NestJS and tsyringe use decorators plus reflect-metadata to auto-wire constructor parameters. This relies on the legacy decorator implementation, which requires experimentalDecorators and emitDecoratorMetadata in tsconfig.json (the new TS 5.0 standard decorators do not yet emit parameter metadata).

import "reflect-metadata";
import { injectable, inject, container } from "tsyringe";

@injectable()
class NotificationService {
  constructor(@inject("Mailer") private readonly mailer: Mailer) {}
}

container.register<Mailer>("Mailer", { useClass: SmtpMailer });
const svc = container.resolve(NotificationService);
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

The container reads the emitted parameter metadata to know what to inject, so you never write new by hand. This convenience is why most TypeScript DI frameworks still depend on the legacy decorator flavor.

When to use and pitfalls

ApproachBest forTrade-off
Manual constructor injectionSmall/medium apps, librariesYou wire the graph by hand
Lightweight containerMany shared servicesA little registration boilerplate
Decorator frameworkLarge apps (NestJS)Needs reflect-metadata + legacy decorators

Inject interfaces, not concrete classes. Depending on Mailer rather than SmtpMailer is what actually makes a dependency substitutable — a container alone does not give you that.

Use DI whenever a class has collaborators you want to mock, swap, or share — which is almost always for services. For tiny scripts or pure functions with no external dependencies, plain imports are simpler and DI is overkill.

Best Practices

  • Inject dependencies through the constructor and type them as interfaces.
  • Use private readonly parameter properties to store and seal dependencies concisely.
  • Depend on abstractions, never concrete implementations, to keep collaborators swappable.
  • Keep all wiring in one composition root rather than scattering new calls.
  • Use symbol tokens (or class references) so providers can’t collide.
  • Reach for a framework only when the object graph is genuinely large.
  • Enable experimentalDecorators + emitDecoratorMetadata only if your DI library needs them.
Last updated June 29, 2026
Was this helpful?