DEV Community

Cover image for Typescript deserves a better dependency injection framework đź’‰
Oleksandr Demian
Oleksandr Demian

Posted on

Typescript deserves a better dependency injection framework đź’‰

Dependency Injection (DI), also known as Inversion of Control (IoC), is a powerful design pattern that allows to write modular apps, making them easier to maintain and scaled.

The State of Dependency Injection in TypeScript

In TypeScript, there are several popular libraries for dependency injection. The major DI libraries and frameworks that use DI in TypeScript are:

All of these frameworks rely on the reflect-metadata library, which is a useful tool that enables libraries like TypeORM to work. However, when used as the backbone of DI and IoC frameworks, reflect-metadata reveals some key limitations.

How Reflect-Metadata Works and Its Limitations in DI/IoC

The reflect-metadata library helps TypeScript collect metadata about classes, such as constructor argument types and method signatures, at runtime. While this approach works for most use cases, it falls short in a few critical areas for TypeScript-based DI:

  • It doesn’t provide information about interfaces implemented by classes.
  • It doesn't retain information about class inheritance (i.e., whether a class extends another class).
  • It ignores type or interface definitions, which are central to TypeScript.

Let’s see these limitations in action.

A Basic Example Without Interfaces

@Component()
class ToBeInjected {}

@Component()
class MainApp {
  constructor(toBeInjected: ToBeInjected) {}
}
Enter fullscreen mode Exit fullscreen mode

In this case, the DI container knows that:

  • ToBeInjected is a component.
  • MainApp requires an instance of ToBeInjected.

This is straightforward for the DI container, as the necessary metadata is available at runtime. However, things get more complicated with interfaces.

The Problem with Interfaces

interface IService {}

@Component()
class ToBeInjected implements IService {}

@Component()
class MainApp {
  constructor(toBeInjected: IService) {}
}
Enter fullscreen mode Exit fullscreen mode

Here, our DI container will fail because reflect-metadata cannot track interfaces. It can only handle classes and primitive types. The container won't know that ToBeInjected implements IService, and so it can’t inject the correct dependency.

Similarly, if ToBeInjected extended another class (e.g., Service), the container would still fail because reflect-metadata doesn’t track class inheritance.

You can read more about these issues in the Angular documentation.

How Existing DI Frameworks Tackle These Problems

The concept of Injection Tokens is used to resolve such issues. An Injection Token is a unique identifier (usually a string or symbol) that tells the DI container which component to inject.

In frameworks like NestJS, this is how you would handle injecting an interface:

interface IAppService {
  getHello(): string;
}

class AppService implements IAppService {
  getHello(): string {
    return 'Hello World!';
  }
}

export const connectionProvider = {
  provide: 'SERVICE', // Injection Token
  useFactory: () => new AppService(),
};

@Injectable()
class App {
  constructor(@Inject('SERVICE') appService: IAppService) {
    console.log(appService.getHello());
  }
}
Enter fullscreen mode Exit fullscreen mode

The Downsides

While Injection Tokens allow for flexibility, they come with trade-offs:

  1. More manual work: You must explicitly provide Injection Tokens when working with non-class components (like interfaces or types).
  2. Lack of type safety: The injection (@Inject('SERVICE') appService: IAppService) doesn't guarantee that the injected value will be of the correct type. This leads to potential issues if, for instance, you refactor a class to implement a different interface — the DI container won’t catch these errors at compile time, and you’ll only encounter them at runtime.

Can TypeScript Have a Better DI Framework?

To make DI in TypeScript better, we need to define what “better” means. A more robust DI framework should:

  • Offer decorator-based DI.
  • Support injecting components based on interfaces and extended classes.
  • Minimize the need for manual Injection Tokens.
  • Support injecting type definitions (TypeScript is all about types!).
  • Ensure strong type safety to prevent injection errors.

Frameworks like Angular and NestJS cover the first bullet point but fall short on the others. So, I set out to create a solution that checks all the boxes.

A New Approach to DI in TypeScript

After exploring several ideas, I realized that reflect-metadata wasn’t up to the task. Instead, I could build a system that collects metadata at build time and uses unique identifiers for types (classes, interfaces, and types) to ensure type safety and flexibility.

Here’s what I came up with:

  • A custom build script (based on TypeScript and ts-morph) scans and processes the code at build time.
  • Unique type identifiers (strings) are created for all types.
  • These identifiers are injected into class prototypes, enabling them to be reused at runtime for DI.

With this approach, I was able to create a DI framework that supports interfaces, types, and class inheritance, all while ensuring type safety.

You can check out the full solution in the LemonDI repository.

Examples of Better DI in Action

Injecting Components via Interfaces

In traditional DI frameworks like NestJS or Angular, you'd need to use Injection Tokens to inject interfaces. However, with LemonDI, injecting components via interfaces works seamlessly:

interface IService {
  sayHi(): void;
}

@Component()
class ComponentA implements IService {
  sayHi() {
    console.log("Hi!");
  }
}

@Component()
class App {
  constructor(toBeInjected: IService) {
    toBeInjected.sayHi(); // ComponentA is automatically injected because it implements IService
  }
}

// start the app
start({
  importFiles: [],
  modules: [App],
});
Enter fullscreen mode Exit fullscreen mode

Support for type Injection

LemonDI also supports injecting types directly (like you would with interfaces), allowing you to inject complex structures like objects and external types easily:

type TConfig = {
  appName: string;
};

@Factory() // Factory creates components that aren't class-based
class ServiceFactory {
  @Instantiate() // Decorator for automatic instantiation
  instantiateAppConfig(): TConfig {
    return { appName: "Dependency Injection App" };
  }
}

@Component()
export class App {
  constructor(appConfig: TConfig) {
    console.log(appConfig.appName); // Output: Dependency Injection App
  }
}

// start the app
start({
  importFiles: [],
  modules: [App],
});
Enter fullscreen mode Exit fullscreen mode

Ensuring Type Safety

One of the key motivations for building LemonDI was to ensure type safety. Unlike other frameworks that rely on Injection Tokens (which can lead to runtime errors), LemonDI ensures that the correct types are injected by matching based on type identifiers.

For example, if you change a service's implementation, LemonDI will catch the error at startup:

// previous interface
interface IAppService {
  getHello(): string;
}

// new interface
interface IAppNewService {
  getNewHello(): void;
}

export class AppService implements IAppNewService {
  getNewHello(): string {
    return 'Hello World!';
  }
}

@Factory()
class ComponentsFactory {
  @Instantiate({
    qualifier: "AppService"
  })
  instantiateAppService(): AppService {
    return new AppService();
  }
}

@Component()
export class App {
  constructor(@Qualifier("AppService") appService: IAppService) {
    console.log(appService.getHello());
  }
}

start({
  importFiles: [], // we don't have to import files
  modules: [App],
});
Enter fullscreen mode Exit fullscreen mode

In this case, LemonDI will throw a start time error because there are no components qualified as 'AppService' that implement IAppService.

Conclusion

TypeScript’s static type system is a powerful tool, but current DI frameworks often fail to leverage it fully. By collecting metadata at build time and ensuring type safety with unique type identifiers, it's possible to create a DI framework that minimizes boilerplate, reduces errors, and fully supports TypeScript's strengths.

What I'm trying to achieve with LemonDI is to move beyond the limitations of reflect-metadata and take full advantage of TypeScript’s type system, making dependency injection in TypeScript more powerful, flexible, and type-safe.

🚧 Bear in mind that this project is in a very early stage and should not be used for production apps.

Top comments (0)