Fundamentals 35 min read

Understanding Dependency Injection and Inversion of Control with TypeScript

This article explains the concepts of Inversion of Control and Dependency Injection, demonstrates how to implement a lightweight DI container in TypeScript using decorators and reflection, and covers advanced features such as provider registration, abstraction, lazy loading, and handling circular dependencies.

ByteFE
ByteFE
ByteFE
Understanding Dependency Injection and Inversion of Control with TypeScript

Preparation Before Reading

Before reading this document, you may want to familiarize yourself with the following concepts to follow the content more easily:

Conceptual knowledge: Inversion of Control, Dependency Injection, Dependency Inversion Principle;

Technical knowledge: Decorator, Reflect;

TSyringe definitions: Token, Provider https://github.com/microsoft/tsyringe#injection-token .

All implementations are available on CodeSandbox; you can explore the source code at https://codesandbox.io/s/di-playground-oz2j9 .

What Is Dependency Injection

Simple Example

We illustrate dependency injection with a simple example: a student travels to school using a transportation tool.

<span class="keyword">class</span> Transportation {
  drive() {
    console.log('driving by transportation')
  }
}

<span class="keyword">class</span> Student {
  transportation = <span class="keyword">new</span> Transportation()
  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

In real life, distant students might drive while nearby students bike. We can further abstract the code:

<span class="keyword">class</span> Car <span class="keyword">extends</span> Transportation {
  drive() {
    console.log('driving by car')
  }
}

<span class="keyword">class</span> FarStudent <span class="keyword">extends</span> Student {
  transportation = <span class="keyword">new</span> Car()
}

Although this satisfies the requirement, it tightly couples each student type to a concrete transportation class, making the code hard to maintain and test.

Inversion of Control

Inversion of Control (IoC) is a design principle that reduces coupling by inverting the flow of control.

An IoC container (or DI tool) acts as the container that resolves dependencies, improving reusability and readability.

Reference: Martin Fowler’s article on Inversion of Control and Dependency Injection https://martinfowler.com/articles/injection.html

Dependency Injection

Dependency Injection (DI) is a concrete implementation of IoC where object creation is delegated to an external container.

Four common DI methods:

Interface‑based injection (rare in front‑end DI tools);

Setter‑based injection (rare in front‑end DI tools);

Constructor‑based injection;

Annotation‑based injection (e.g., @Inject).

Constructor‑based injection example:

<span class="keyword">class</span> Student {
  <span class="keyword">constructor</span>(transportation: Transportation) {
    this.transportation = transportation
  }
  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

<span class="keyword">class</span> Car <span class="keyword">extends</span> Transportation {
  drive() { console.log('driving by car') }
}

<span class="keyword">const</span> car = <span class="keyword">new</span> Car()
<span class="keyword">const</span> student = <span class="keyword">new</span> Student(car)
student.gotoSchool()

Manual constructor injection works but quickly becomes cumbersome when objects have many nested dependencies.

DI Tools

DI tools (IoC containers) automate dependency analysis and object creation.

<span class="annotation">@Injectable</span>()
<span class="keyword">class</span> Student {
  <span class="keyword">constructor</span>(transportation: Transportation) {
    this.transportation = transportation
  }
  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

<span class="keyword">const</span> injector = <span class="keyword">new</span> Injector()
<span class="keyword">const</span> student = injector.create(Student)
student.gotoSchool()

Annotation‑based injection example:

<span class="annotation">@Injectable</span>()
<span class="keyword">class</span> Student {
  <span class="annotation">@Inject</span>()
  <span class="keyword">private</span> transportation: Transportation

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

<span class="keyword">const</span> injector = <span class="keyword">new</span> Injector()
<span class="keyword">const</span> student = injector.create(Student)
student.gotoSchool()

The constructor‑based version can be instantiated manually, while the annotation version requires the container.

Dependency Inversion Principle

The Dependency Inversion Principle (DIP) states that high‑level modules should not depend on low‑level modules; both should depend on abstractions.

Applying DIP, we depend on an abstract Drivable interface instead of a concrete class:

<span class="comment">// src/transportations/car.ts</span>
<span class="keyword">class</span> Car {
  drive() { console.log('driving by car') }
}

<span class="comment">// src/students/student.ts</span>
<span class="keyword">interface</span> Drivable { drive(): void }

<span class="keyword">class</span> Student {
  <span class="keyword">constructor</span>(transportation: Drivable) {
    this.transportation = transportation
  }
  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}

Abstracting dependencies improves cohesion, reduces coupling, and simplifies project structure, especially in mixed SSR/CSR environments.

We can also abstract data‑fetching services:

<span class="keyword">interface</span> IUserService {
  getUserInfo(): Promise<{ name: string }>
}

<span class="keyword">class</span> Page {
  <span class="keyword">constructor</span>(userService: IUserService) { this.userService = userService }
  async render() {
    const user = await this.userService.getUserInfo()
    return `<h1> My name is ${user.name}. </h1>`
  }
}

Client‑side implementation:

<span class="keyword">class</span> WebUserService <span class="keyword">implements</span> IUserService {
  async getUserInfo() { return fetch('/api/users/me') }
}

<span class="keyword">const</span> userService = <span class="keyword">new</span> WebUserService()
<span class="keyword">const</span> page = <span class="keyword">new</span> Page(userService)
page.render().then(html => { document.body.innerHTML = html })

Server‑side implementation:

<span class="keyword">class</span> ServerUserService <span class="keyword">implements</span> IUserService {
  async getUserInfo() { return db.query('...') }
}

<span class="keyword">class</span> HomeController {
  async renderHome() {
    const userService = <span class="keyword">new</span> ServerUserService()
    const page = <span class="keyword">new</span> Page(userService)
    ctx.body = await page.render()
  }
}

Testability

DI improves testability by allowing dependencies to be stubbed or mocked without altering the class under test.

Without DI (using prototype stubs):

it('goto school successfully', async () => {
  const driveStub = sinon.sub(Car.prototype, 'drive').resolve()
  const student = new Student()
  student.gotoSchool()
  expect(driveStub.callCount).toBe(1)
})

With DI (injecting a fake implementation):

it('goto school successfully', async () => {
  const driveFn = sinon.fake()
  const student = new Student({ drive: driveFn })
  student.gotoSchool()
  expect(driveFn.callCount).toBe(1)
})

Circular Dependencies

When two classes depend on each other, naive instantiation fails. Using lazy proxies or token factories can break the cycle.

<span class="keyword">export</span> class Foo {
  <span class="keyword">constructor</span>(public bar: Bar) {}
}

<span class="keyword">export</span> class Bar {
  <span class="keyword">constructor</span>(public foo: Foo) {}
}

DI can provide a proxy that resolves the real instance only when accessed, preventing immediate circular construction.

Community Tools

Popular DI libraries for TypeScript include:

InversifyJS – powerful but verbose ( https://github.com/inversify/InversifyJS );

TSyringe – simple and Angular‑inspired ( https://github.com/microsoft/tsyringe ).

Implementing Core Capabilities

To build a basic DI tool we need three capabilities:

Dependency analysis – discover what each class requires;

Registration of creators – support class, value, and factory providers;

Instance creation – handle singleton and transient lifetimes.

Example usage (CodeSandbox link): https://codesandbox.io/s/di-playground-oz2j9

<span class="annotation">@Injectable</span>()
<span class="keyword">class</span> Transportation {
  drive() { console.log('driving by transportation') }
}

<span class="annotation">@Injectable</span>()
<span class="keyword">class</span> Student {
  <span class="keyword">constructor</span>(private transportation: Transportation) {}
  gotoSchool() { console.log('go to school by'); this.transportation.drive() }
}

<span class="keyword">const</span> container = <span class="keyword">new</span> Container()
<span class="keyword">const</span> student = container.resolve(Student)
student.gotoSchool()

Dependency Analysis

Enable TypeScript decorators and metadata:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Compiled output shows the metadata attached to the class:

Student = __decorate([
  Injectable(),
  __metadata("design:paramtypes", [Transportation])
], Student);

Reflect can retrieve the constructor parameter types:

const args = Reflect.getMetadata("design:paramtypes", Student)
expect(args).toEqual([Transportation])

Decorator Implementation

The Injectable decorator records dependencies and marks the class as injectable:

<span class="keyword">const</span> DESIGN_TYPE_NAME = { DesignType: "design:type", ParamType: "design:paramtypes", ReturnType: "design:returntype" }
<span class="keyword">const</span> DECORATOR_KEY = { Injectable: Symbol.for("Injectable") }

<span class="keyword">export</span> <span class="keyword">function</span> Injectable<T>() {
  <span class="keyword">return</span> (target: <span class="keyword">new</span>(...args: any[]) => T) => {
    <span class="keyword">const</span> deps = Reflect.getMetadata(DESIGN_TYPE_NAME.ParamType, target) || []
    Reflect.defineMetadata(DECORATOR_KEY.Injectable, { deps }, target)
  }
}

Container Definition

The container resolves a class by recursively resolving its dependencies:

<span class="keyword">class</span> ContainerV1 {
  resolve<T>(target: ConstructorOf<T>): T {
    const injectableOpts = this.parseInjectableOpts(target)
    const args = injectableOpts.deps.map(dep => this.resolve(dep))
    return <span class="keyword">new</span> target(...args)
  }
  private parseInjectableOpts(target: ConstructorOf<any>): InjectableOpts {
    const ret = Reflect.getOwnMetadata(DECORATOR_KEY.Injectable, target)
    if (!ret) throw new Error("Constructor should be wrapped with decorator Injectable.")
    return ret
  }
}

Providers and Registration

Define Token and Provider types, then allow registration of class, value, and factory providers.

<span class="keyword">type</span> Token<T = any> = string | symbol | ConstructorOf<T>

interface ClassProvider<T = any> { token: Token<T>; useClass: ConstructorOf<T> }
interface ValueProvider<T = any> { token: Token<T>; useValue: T }
interface FactoryProvider<T = any> { token: Token<T>; useFactory(c: ContainerInterface): T }

ContainerV2 stores providers in a Map and resolves them accordingly.

<span class="keyword">class</span> ContainerV2 implements ContainerInterface {
  private providerMap = new Map<Token, Provider>()
  resolve<T>(token: Token<T>): T { /* resolve based on provider type */ }
  register(...providers: Provider[]) { providers.forEach(p => this.providerMap.set(p.token, p)) }
}

Instance Replacement

Register a different class for a token to replace the default implementation:

const container = new ContainerV2()
container.register({ token: Transportation, useClass: Bicycle })
const student = container.resolve(Student)
student.gotoSchool()

Factory Provider

Use a factory to decide which implementation to provide at runtime:

container.register({
  token: Transportation,
  useFactory: c => weekday > 5 ? c.resolve(Car) : c.resolve(Bicycle)
})

Abstract Dependencies

Define an abstract token (symbol) and inject it via the @Inject decorator:

const ITransportation = Symbol.for('ITransportation')
interface ITransportation { drive(): string }

@Injectable()
class StudentWithAbstraction extends Student {
  constructor(@Inject(ITransportation) protected transportation: ITransportation) {
    super(transportation)
  }
}

Lazy Creation

Property decorators can create a getter that resolves the dependency only when accessed:

function decorateProperty(_1: object, _2: string | symbol, token: Token) {
  const valueKey = Symbol.for('PropertyValue')
  return {
    get(this: any) {
      if (!this.hasOwnProperty(valueKey)) {
        const container: IContainer = this[REFLECT_KEY.Container]
        this[valueKey] = container.resolve(token)
      }
      return this[valueKey]
    }
  }
}

Resolving Circular Dependencies

Break cycles by depending on abstractions (e.g., IFather, ISon) and registering concrete providers, or use lazy injection with a token factory.

@Injectable()
class FatherWithAbstraction {
  @Inject(ISon) son!: IPerson
  name = "Durotan"
  getDescription() { return `I am ${this.name}, my son is ${this.son.name}.` }
}

@Injectable()
class SonWithAbstraction {
  @Inject(IFather) father!: IPerson
  name = "Thrall"
  getDescription() { return `I am ${this.name}, son of ${this.father.name}.` }
}

const container = new ContainerV2(
  { token: IFather, useClass: FatherWithAbstraction },
  { token: ISon, useClass: SonWithAbstraction }
)

LazyInject with TokenFactory

Define a decorator that accepts a function returning a token, enabling lazy resolution of circular references.

export function LazyInject(tokenFn: () => Token) {
  return (target: ConstructorOf<any> | object, key: string | symbol, index?: number) => {
    if (typeof index !== 'number' || typeof target === 'object') {
      return decorateProperty(target, key, { getToken: tokenFn })
    } else {
      return decorateConstructorParameter(target, index, { getToken: tokenFn })
    }
  }
}

ContainerV3 extends ContainerV2 to resolve either a token or a token factory.

class ContainerV3 extends ContainerV2 {
  resolve<T>(tokenOrFactory: Token<T> | TokenFactory<T>): T {
    const token = typeof tokenOrFactory === 'object' ? tokenOrFactory.getToken() : tokenOrFactory
    return super.resolve(token)
  }
}

Conclusion

We have built a functional DI tool that demonstrates:

Reflection‑based dependency analysis and object creation;

Provider registration for instance replacement, abstraction, and factory patterns;

Lazy creation via property decorators;

Dynamic token factories to resolve circular dependencies.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Design PatternsTypeScriptdependency-injectionInversion of ControlIoC Container
ByteFE
Written by

ByteFE

Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.