Right, so you’ve decided to build something that doesn’t turn into a Jenga tower of code. Good choice. You’ve probably heard the term “Dependency Injection” (DI) thrown around like confetti at a programmer’s wedding. Let’s cut through the jargon: at its heart, DI is just a fancy way of saying “give a thing its dependencies from the outside, rather than letting it build them itself.” It’s the difference between a chef going to a well-stocked pantry (you, the injector) and a chef who also has to grow the wheat and raise the chickens. The latter is impressive, but a nightmare to manage when you just want to cook dinner.

In TypeScript-land, we don’t have a built-in DI container like some other languages, so we turn to libraries. The two heavyweights are InversifyJS and tsyringe. Inversify is the full-featured, enterprise-grade toolbox—powerful but with a bit more setup. tsyringe is Microsoft’s offering: a lighter, more pragmatic, “just works” approach that leverages TypeScript’s emit metadata functionality. I’ll show you both because, frankly, you’ll probably see both in the wild.

The Why: It’s All About Control (And Testing)

The immediate benefit you’ll feel is in testing. Imagine a UserService that needs a DatabaseConnection. Without DI, it might new DatabaseConnection() up directly. To test UserService, you now have to talk to a real database. That’s slow, flaky, and absurd.

With DI, you inject the DatabaseConnection. In your real app, you inject the real one. In your test, you inject a mock or a stub. You’ve now surgically isolated the thing you’re actually trying to test. This is the single biggest reason to adopt DI. The secondary reasons are equally important: it makes your code more modular, reusable, and frankly, easier to reason about because dependencies are explicit.

Getting Started with InversifyJS

Inversify uses a slightly more verbose but incredibly explicit system of decorators and symbols. First, you need to set up your environment. You’ll need the reflect-metadata shim and to configure your tsconfig.json correctly.

// tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    // ... your other options
  }
}
npm install inversify reflect-metadata

Now, let’s define an interface and its concrete implementation. We use Symbols as keys because they are unique and avoid the pitfalls of string-based lookups.

// types.ts
export const TYPES = {
  Warrior: Symbol.for('Warrior'),
  Weapon: Symbol.for('Weapon')
};

// interfaces.ts
export interface Warrior {
  fight(): string;
}

export interface Weapon {
  hit(): string;
}

// entities.ts
import { injectable, inject } from 'inversify';
import { Warrior, Weapon } from './interfaces';
import { TYPES } from './types';

@injectable()
export class Knight implements Warrior {
  public constructor(
    @inject(TYPES.Weapon) private readonly _weapon: Weapon
  ) {}

  public fight() { return this._weapon.hit(); }
}

@injectable()
export class Sword implements Weapon {
  public hit() { return "cut!"; }
}

Finally, we bind the interfaces to their implementations in a container.

// container.ts
import { Container } from 'inversify';
import { TYPES } from './types';
import { Warrior, Weapon } from './interfaces';
import { Knight, Sword } from './entities';

const myContainer = new Container();
myContainer.bind<Warrior>(TYPES.Warrior).to(Knight);
myContainer.bind<Weapon>(TYPES.Weapon).to(Sword);

// Now, somewhere in your app
const knight = myContainer.get<Warrior>(TYPES.Warrior);
console.log(knight.fight()); // Output: "cut!"

See how the Knight never says new Sword()? It just knows it will get a Weapon. You’re in control of which one.

The Lighter Alternative: tsyringe

tsyringe takes a more opinionated approach. It uses TypeScript’s type metadata, which means you often don’t need to deal with symbols or manual interface binding. It feels more magical, but also simpler.

npm install tsyringe reflect-metadata

Make sure reflect-metadata is imported at your app’s entry point (e.g., index.ts): import "reflect-metadata";

Now, let’s recreate the same example.

// interfaces.ts
export interface Warrior {
  fight(): string;
}

export interface Weapon {
  hit(): string;
}

// entities.ts
import { injectable, inject } from 'tsyringe';

@injectable()
export class Knight implements Warrior {
  public constructor(
    @inject('Weapon') private readonly _weapon: Weapon
  ) {}

  public fight() { return this._weapon.hit(); }
}

@injectable()
export class Sword implements Weapon {
  public hit() { return "cut!"; }
}

Notice the key difference: @inject('Weapon') uses a string token. You can also use a class itself as a token, which is often cleaner for concrete classes.

// Using a Class as a Token (tsyringe)
import { injectable, inject } from 'tsyringe';

@injectable()
export class Knight implements Warrior {
  public constructor(
    @inject(Sword) private readonly _weapon: Weapon // Injects a Sword
  ) {}
}

To register and resolve, it’s a bit different. tsyringe has a registry decorator, but often you don’t even need it for concrete classes.

// app.ts
import 'reflect-metadata';
import { container } from 'tsyringe';
import { Knight } from './entities';

// If you need to register an interface to an implementation:
container.register('Weapon', { useClass: Sword });

// But for the Knight itself, since it's a concrete class, you can just resolve it:
const knight = container.resolve(Knight);
console.log(knight.fight()); // Output: "cut!"

Common Pitfalls and The “Oh Crap” Moments

  1. The Missing reflect-metadata: You will forget to import "reflect-metadata" at your app’s root. Your dependencies will be undefined and you will waste 30 minutes wondering why. I’ve done it. We’ve all done it.

  2. Circular Dependencies: This is the classic “Class A needs Class B, which needs Class A.” Inversify and tsyringe will throw cryptic errors. The solution is almost always to refactor your design to break the cycle, often by introducing an interface.

  3. Over-Engineering: Don’t inject everything. Don’t create a DI container for a 200-line script. It’s a tool for complex applications. Injecting a plain configuration object? Great. Injecting a string literal for the name of your app? You’ve jumped the shark.

  4. Scopes: Understand singleton vs transient. By default, most containers return a singleton. This means every time you get or resolve, you get the same instance. This is great for a database connection pool. It’s disastrous for a class that holds user-specific state in a web request! For that, you need a transient or request-scoped lifetime. Both libraries handle this:

    // Inversify - Transient binding
    myContainer.bind<Weapon>(TYPES.Weapon).to(Sword).inTransientScope();
    
    // tsyringe - Transient registration
    container.register('Weapon', { useClass: Sword }, { lifecycle: Lifecycle.Transient });
    

The choice between Inversify and tsyringe isn’t about which is “better.” It’s about taste. Inversify gives you more explicit control. tsyringe offers faster setup and less boilerplate. For most projects, I find tsyringe’s pragmatism wins out. But if you need the absolute granular control and don’t mind the symbols, Inversify is a brilliant, battle-tested choice. Either way, you’re now building with seams, and that’s what separates a house of cards from actual architecture.