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.

class
Transportation {
  drive() {
    console.log('driving by transportation')
  }
}
class
Student {
  transportation =
new
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:

class
Car
extends
Transportation {
  drive() {
    console.log('driving by car')
  }
}
class
FarStudent
extends
Student {
  transportation =
new
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:

class
Student {
constructor
(transportation: Transportation) {
    this.transportation = transportation
  }
  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}
class
Car
extends
Transportation {
  drive() { console.log('driving by car') }
}
const
car =
new
Car()
const
student =
new
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.

@Injectable
()
class
Student {
constructor
(transportation: Transportation) {
    this.transportation = transportation
  }
  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}
const
injector =
new
Injector()
const
student = injector.create(Student)
student.gotoSchool()

Annotation‑based injection example:

@Injectable
()
class
Student {
@Inject
()
private
transportation: Transportation

  gotoSchool() {
    console.log('go to school by')
    this.transportation.drive()
  }
}
const
injector =
new
Injector()
const
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:

// src/transportations/car.ts
class
Car {
  drive() { console.log('driving by car') }
}
// src/students/student.ts
interface
Drivable { drive(): void }
class
Student {
constructor
(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:

interface
IUserService {
  getUserInfo(): Promise<{ name: string }>
}
class
Page {
constructor
(userService: IUserService) { this.userService = userService }
  async render() {
    const user = await this.userService.getUserInfo()
    return `
My name is ${user.name}.
`
  }
}

Client‑side implementation:

class
WebUserService
implements
IUserService {
  async getUserInfo() { return fetch('/api/users/me') }
}
const
userService =
new
WebUserService()
const
page =
new
Page(userService)
page.render().then(html => { document.body.innerHTML = html })

Server‑side implementation:

class
ServerUserService
implements
IUserService {
  async getUserInfo() { return db.query('...') }
}
class
HomeController {
  async renderHome() {
    const userService =
new
ServerUserService()
    const page =
new
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.

export
class Foo {
constructor
(public bar: Bar) {}
}
export
class Bar {
constructor
(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

@Injectable
()
class
Transportation {
  drive() { console.log('driving by transportation') }
}
@Injectable
()
class
Student {
constructor
(private transportation: Transportation) {}
  gotoSchool() { console.log('go to school by'); this.transportation.drive() }
}
const
container =
new
Container()
const
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:

const
DESIGN_TYPE_NAME = { DesignType: "design:type", ParamType: "design:paramtypes", ReturnType: "design:returntype" }
const
DECORATOR_KEY = { Injectable: Symbol.for("Injectable") }
export
function
Injectable<T>() {
return
(target:
new
(...args: any[]) => T) => {
const
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:

class
ContainerV1 {
  resolve<T>(target: ConstructorOf<T>): T {
    const injectableOpts = this.parseInjectableOpts(target)
    const args = injectableOpts.deps.map(dep => this.resolve(dep))
    return
new
target(...args)
  }
  private parseInjectableOpts(target: ConstructorOf
): 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.

type
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.

class
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
| 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
(tokenOrFactory: Token
| TokenFactory
): 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.

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

login 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.