Skip to content
TypeScript ts oop 4 min read

Dependency Injection Basics

Dependency injection (DI) is the practice of giving a class the objects it needs from the outside rather than letting it construct them itself. It is the concrete technique that fulfils the Dependency Inversion Principle: code depends on abstractions and receives implementations as parameters. In TypeScript, interfaces describe those abstractions and constructors are the natural injection point. This page covers constructor injection, programming to interfaces, building a minimal manual container, and where mature frameworks like NestJS and Inversify take over.

The problem DI solves

When a class creates its own collaborators with new, it is welded to those concrete types. You cannot swap the implementation for a test double, and changing one dependency ripples outward. DI flips control: the caller decides what to inject.

// ❌ Tightly coupled: cannot test or swap the API client
class WeatherWidget {
  private api = new HttpWeatherApi("https://api.example.com");
  async render(city: string) {
    return (await this.api.get(city)).temp;
  }
}

Because HttpWeatherApi is hard-coded, every test of WeatherWidget hits the network. The fix is to accept the dependency instead of building it.

Programming to interfaces

DI is most valuable when you depend on an interface, not a concrete class. The interface is the contract; many implementations can satisfy it. Define the abstraction first, then make classes implement it.

interface WeatherApi {
  get(city: string): Promise<{ temp: number }>;
}

class HttpWeatherApi implements WeatherApi {
  constructor(private readonly baseUrl: string) {}
  async get(city: string) {
    const res = await fetch(`${this.baseUrl}/weather?city=${city}`);
    return res.json() as Promise<{ temp: number }>;
  }
}

class FakeWeatherApi implements WeatherApi {
  async get() {
    return { temp: 21 };
  }
}

WeatherWidget will depend on WeatherApi, never on HttpWeatherApi directly. The HTTP version runs in production; the fake runs in tests — both are valid WeatherApi values.

Constructor injection

The idiomatic way to inject in TypeScript is through the constructor. Use a parameter property (private readonly) to declare and assign the dependency in one line. The type is the interface, so the class never names a concrete implementation.

class WeatherWidget {
  constructor(private readonly api: WeatherApi) {}

  async render(city: string): Promise<number> {
    const { temp } = await this.api.get(city);
    return temp;
  }
}

// Production wiring
const widget = new WeatherWidget(new HttpWeatherApi("https://api.example.com"));

// Test wiring
const testWidget = new WeatherWidget(new FakeWeatherApi());

Constructor injection makes dependencies explicit and guarantees the object is fully initialised once constructed. It also documents requirements: reading the constructor tells you exactly what the class needs.

Prefer constructor injection over setter or property injection. A required dependency passed via the constructor can be readonly and can never be undefined, so the compiler guarantees it is present.

Manual wiring at the composition root

For small apps you do not need a framework. Wire everything once, at the program’s entry point — the “composition root.” This keeps construction logic in a single place and the rest of the code dependency-free.

// main.ts — the composition root
const api: WeatherApi = new HttpWeatherApi(process.env.API_URL!);
const widget = new WeatherWidget(api);

await widget.render("London");

Centralising construction means there is exactly one spot to change when you swap implementations or add configuration. Everything downstream simply receives what it needs.

A simple DI container

As graphs grow, manual wiring gets repetitive. A tiny container stores factories keyed by a token and resolves them on demand. This illustrates what frameworks automate.

class Container {
  private factories = new Map<string, () => unknown>();

  register<T>(token: string, factory: (c: Container) => T): void {
    this.factories.set(token, () => factory(this));
  }

  resolve<T>(token: string): T {
    const factory = this.factories.get(token);
    if (!factory) throw new Error(`No provider for ${token}`);
    return factory() as T;
  }
}

const container = new Container();
container.register<WeatherApi>("WeatherApi", () => new HttpWeatherApi("https://api.example.com"));
container.register("WeatherWidget", (c) => new WeatherWidget(c.resolve<WeatherApi>("WeatherApi")));

const widget = container.resolve<WeatherWidget>("WeatherWidget");

The container resolves a dependency’s own dependencies recursively. Note the trade-off: string tokens lose some compile-time safety, which is why real containers use symbols, classes, or decorators as keys.

Lifetimes: transient vs singleton

A container also controls how often an instance is created. A transient provider returns a fresh object each time; a singleton caches and reuses one.

LifetimeBehaviourTypical use
TransientNew instance per resolveStateless helpers, value objects
SingletonOne shared instanceConfig, DB pool, loggers
ScopedOne per request/scopePer-request services in web apps

Most frameworks default to singletons for services and offer scoped lifetimes for web requests so each request gets isolated state.

Frameworks: NestJS and Inversify

In production TypeScript, dedicated DI frameworks remove the boilerplate. NestJS has DI built in: mark a class @Injectable(), list dependencies in the constructor, and the framework resolves the graph from module providers. InversifyJS is a standalone container using decorators and symbol tokens.

// NestJS — DI is automatic from constructor types
import { Injectable } from "@nestjs/common";

@Injectable()
class WeatherWidget {
  constructor(private readonly api: WeatherApi) {}
}

Both rely on the legacy decorator metadata flow — enable experimentalDecorators and emitDecoratorMetadata in tsconfig.json and import reflect-metadata, so the container can read constructor parameter types at runtime.

Best Practices

  • Inject dependencies through the constructor and store them as private readonly fields.
  • Depend on interfaces, not concrete classes, so implementations stay swappable.
  • Keep all object construction at a single composition root rather than scattered new calls.
  • Reach for a container only when manual wiring becomes genuinely repetitive.
  • Choose lifetimes deliberately: singletons for shared state, transient for stateless helpers.
  • Use a real framework (NestJS, Inversify) for large graphs instead of hand-rolling a container.
  • Inject fakes in tests to keep units fast and isolated from I/O.
Last updated June 29, 2026
Was this helpful?