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
| Approach | Best for | Trade-off |
|---|---|---|
| Manual constructor injection | Small/medium apps, libraries | You wire the graph by hand |
| Lightweight container | Many shared services | A little registration boilerplate |
| Decorator framework | Large apps (NestJS) | Needs reflect-metadata + legacy decorators |
Inject interfaces, not concrete classes. Depending on
Mailerrather thanSmtpMaileris 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 readonlyparameter 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
newcalls. - Use
symboltokens (or class references) so providers can’t collide. - Reach for a framework only when the object graph is genuinely large.
- Enable
experimentalDecorators+emitDecoratorMetadataonly if your DI library needs them.