Typesafe, (almost) Zero Cost Dependency Injection in TypeScript | Pure DI in TypeScript Node.js

Introduction

In this set of articles, I'm going to share my own experience with implementing and using Typesafe Dependency Injection with Registry in TypeScript Node.js backend applications.

So, let's get started with it.

P.S. you can read more about Dependency Inversion vs. Inversion of Control vs. Dependency Injection here and here.

Some Ways of Dependency Injection in TypeScript

The are basically 3 different ways to perform Dependency Injection in TypeScript:

  • Higher-order function Arguments Injection
  • Class Constructor Injection
  • Class Property Injection (will not be covered here since it causes the Temporal Coupling code smell)

Higher-order function

Example:

export function createOrderReceiptGenerator(orderService: OrderService) {
  return {
    generate(orderId: ID) {
      const order = orderService.findById(orderId);

      return orderService.generate(order);
    },
  };
}

Static linking example:

export function main() {
  const orderService = createOrderService();
  const orderReceiptGenerator = createOrderReceiptGenerator(orderService);

  const reciept = await orderReceiptGenerator.generate();
}

Class Constructor Injection

Generally the same idea, but instead of closure, the context is handled by the JavaScript class feature.

export class OrderReceiptGenerator {
  constructor(private readonly orderService: OrderService) {}

  public generate(orderId: ID) {
    const order = this.orderService.findById(orderId);

    return this.orderService.generate(order);
  }
}

Static linking example (aka. Pure DI):

export function main() {
  const orderService = new DefaultOrderService();
  const orderReceiptGenerator = new DefaultOrderReceiptGenerator(orderService);

  const reciept = await orderReceiptGenerator.generate();
}

IoC Container

IoC Container - is a service locator where you can register services by a token and later resolve them by the token:

interface Container {
  register(token: unknown, config: RegistrationConfig): this;
  resolve<T>(token: unknown): T; // throws wnen one of the token in the chain cannot be resolved
}

One of the nice features of IoC containers is that you can define lifetimes for your services. We usually have the settings for the dependency Lifetime as part of RegistrationConfig. Well-known lifetimes:

  • Transient - New instance every time
  • Scoped - Instance per (HTTP request)/(another invocation)
  • Singleton - Instance per application lifetime

With Decorators

This is the most common way to perform Dependency Injection in Typescript.

Unlike Java and C#, when TypeScript is transpiled to JavaScript the type information gets lost, making a TypeScript interface become nothing in JS. Because of this, it's not possible to use the interface itself as a token in the IoC Container. Decorators come in handy here to configure the service metadata in the Container.

There are a bunch of libraries that use decorators:

Example of using inversify decorators:

// order-service.ts
const OrderService = "OrderService";

export interface OrderService {
  findOrderById(orderId: ID): Promise<Order>;
  generateReceipt(order: Order): Promise<Receipt>;
  // ... others
}
// order-receipt-generator.ts
import { inject } from "inversify"; // <-- extra external dependency

export class OrderReceiptGenerator {
  constructor(
    @inject(OrderService) private readonly orderService: OrderService
  ) {}

  // generate receipt
}

Dynamic linking example:

export function main() {
  const container = new Container();

  container
    .bind<OrderService>(OrderService)
    .to(DefaultOrderService)
    .inSingletonScope();

  container
    .bind<OrderReceiptGenerator>(OrderReceiptGenerator)
    .toSelf()
    .inTransientScope();

  // ...later...

  const orderReceiptGenerator = container.resolve(OrderReceiptGenerator);

  const reciept = await orderReceiptGenerator.generate();
}

Unfortunately, the approach with decorators has some issues:

  1. The decorator is still an experimental feature in TypeScript and it's not implemented in JavaScript. However, they are on stage 3 now.
  2. Using the decorators requires importing the reflect-metadata package
  3. The decorators have performance overhead
  4. The decorators make constructors more verbose and less readable
  5. The decorators make a coupling between the business code and the IoC library
  6. Very hard to override the token in inject since the metadata is bonded with the parent class itself

It's worth saying that problems 4-6 have workarounds but they are often not pretty.

Without Decorators

You might also find some libraries that don't depend on decorators, like:

DI-compiler

  • Pros:
    • Typesafe at transpile time
    • Requires no extra dependencies on business code
  • Cons:
    • Requires extra transpile step
    • Not typesafe at dev time

typed-inject

  • Pros:
    • Typesafe at dev time and transpile time
    • Requires no extra dependencies on business code
  • Cons:
    • Requires adding specific public static inject construction to class
    • Works only with Literal Types tokens

Disadvantages

Overall, using the IoC Container is nice and fun, but it also has some disadvantages:

  • Usually not typesafe, aka might lead to runtime errors if a desired token is not defined in the container
  • Hard to know the dependency lifetime when injecting it
  • Injecting short-lifetime dependency into long-lifetime service (for ex. Transient dependency into Singleton service) might lead to unpredicted behavior
  • Runtime performance overhead
  • IoC Container is Service Locator which is considered to be an anti-pattern when used directly

Should we hold and think a little?

Seems like at this point we have a lot of potential issues with the IoC Container approach. At the same time, the Class Constructor Injection seemed very clean but the static linking configuration is hard and messy. How can we achieve the configuration simplicity of the IoC Container with dev-time type safety?

Removing Excess

Before we go to the solution, let's talk about the lifetimes.

Previously, I've mentioned 3 lifetimes: Transient, Scoped, and Singleton, but do we really need them given the disadvantages we have using them all? If we simplify everything to Singleton this might solve most of the problems. So, here is what we can do then:

  • Use Factory when Transient dependency is necessary
  • Replace Scoped dependency with a scope provider

Use Factory when Transient dependency is necessary

Transient dependencies are tricky. They only work properly when the whole chain of resolution is Transient, otherwise, it might lead to unexpected problems. So my solution here would be to use a factory when the new instance is required.

Replace Scoped dependency with a scope provider

The one example of using Scoped dependency that comes to my mind, it's HTTP request level caching for libs like dataloader.

This problem seems tough initially, but fortunately, we can actually solve this easily by using Node.js async_hooks feature. I will provide the solution in the next article.

At this point all our dependencies in the container become Singleton and we can basically replace the container with a single application state object and create it at application startup time (like with did with static linking).

Registry

This approach is inspired by the article and I call it Registry.

Let's start with the example of REST APIs built over fastify:

// orders-routes.ts
export function ordersRoutes(deps: {
  orderReceiptGenerator: OrderReceiptGenerator;
}): FastifyPluginCallback {
  return (fastify, _, next) => {
    fastify.get("/orders/:orderId/receipt", async (request, reply) => {
      const { orderId } = request.params as {
        orderId: string;
      };

      const receipt = await deps.orderReceiptGenerator.generateReceipt(orderId);

      if (!receipt) {
        return reply.status(404).send();
      }

      return reply.send(receipt);
    });

    next();
  };
}

// create-app-registry.ts
export function createAppRegistry() {
  const orderService = new DefaultOrderService();
  const orderReceiptGenerator = new DefaultOrderReceiptGenerator(orderService);

  // will automatically infer the types and fail at transpile type if services are missing or invalid.
  return {
    orderService,
    orderReceiptGenerator,
  };
}

// index.ts

async function main() {
  const registry = createAppRegistry();

  const server = Fastify({});
  server.register(ordersRoutes(registry));

  try {
    await server.listen({ port: PORT });

    console.log("listening on port", PORT);
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
}

main();

Note that I pass the whole registry object into ordersRoutes, while ordersRoutes itself depends only on a slice of services to avoid unnecessary coupling:

export function ordersRoutes(deps: {
  orderReceiptGenerator: OrderReceiptGenerator;
}): FastifyPluginCallback {
  ...

Here we have the zero-cost type safety, with no runtime overhead.

However, this approach has one issue. The dependency graph might grow very fast and the registry composing might become painful.

RegistryComposer

To solve the problem we can use the following simple RegistryComposer class:

export class RegistryComposer<TNeeds extends object = object> {
  private readonly creators: CreateServices<TNeeds, object>[] = [];

  add<TServices extends object>(
    createServices: CreateServices<TNeeds, TServices>
  ): RegistryComposer<Combine<TNeeds, TServices>> {
    this.creators.push(createServices);

    return this as any;
  }

  compose(): Readonly<TNeeds> {
    return Object.freeze(
      this.creators.reduce((state, createServices) => {
        return Object.assign(state, createServices(state));
      }, {} as any)
    );
  }
}

type CreateServices<TNeeds, TServices extends object> = (
  needs: TNeeds
) => TServices;

type Combine<TSource extends object, TWith extends object> = Norm<
  Omit<TSource, keyof TWith> & TWith
>;

type Norm<T> = T extends object
  ? {
      [P in keyof T]: T[P];
    }
  : never;

Note: You can find complete source code here.

The RegistryComposer provides the ability to chain state mutation. Every new call in the chain knows about the previous state modifications. The CreateServices function type is a mapper function to create a new Registry state. The needs argument defines what services the registration depends on, and the function returns new services as a Record.

Note: The Combine type is a helper to override field types when a field with the same name added.

Note: The Norm type is a helper to remove & (intersections) from resulting TypeScript hints.

Calling compose finally composes the registry object:

// create-app-registry.ts
export function createAppRegistry() {
  return new RegistryComposer()
    .add(() => {
      orderService: new DefaultOrderService();
    })
    .add(
      // knowns that the state already contains orderService
      ({ orderService }) => ({
        orderReceiptGenerator: new DefaultOrderReceiptGenerator(orderService),
      })
    )
    .compose();
}

Let's refactor the code to make it prettier:

// create-app-registry.ts
export function createAppRegistry() {
  return new RegistryComposer()
    .add(orderService())
    .add(orderReceiptGenerator())
    .compose();
}

function orderService() {
  return () => {
    const orderService = new DefaultOrderService();

    return {
      orderService,
    };
  };
}

function orderReceiptGenerator{
  return (needs: { orderService: OrderService }) => {
    const orderReceiptGenerator = new DefaultOrderReceiptGenerator(
      needs.orderService
    );

    return {
      orderReceiptGenerator,
    };
  };
}

Looks much better now, we should also move the functions into their files and write tests.

Why almost zero cost?

Obviously, we sacrifice the cold-start performance a little to have the RegistryComposer and nice-looking add functions.

Function Injection

Very common the services tend to grow much if not paid attention (not following the Single Responsibility Principle).

// order-service.ts

export interface OrderService {
  findOrderById(orderId: ID): Promise<Order>;
  addProductToOrder(productId: ID): Promise<void>;
  findByProduct(productId: ID): Promise<readonly Order[]>;
  findByUser(userId: ID): Promise<readonly Order[]>;
  findByCategoryType(categoryType: string): Promise<readonly Order[]>;
  generateReceipt(order: Order): Promise<Receipt>;
  generateStats(): Promise<OrdersStats>;
  // ... others
}

Client code:

// order-receipt-generator.ts

export class OrderReceiptGenerator {
  constructor(private readonly orderService: OrderService) {}

  // generate receipt
}

To fix the issue, as a first step, we can just break up the OrderService interface into smaller interfaces.

// order-finder-service.ts

export interface OrderService {
  findByProduct(productId: ID): Promise<readonly Order[]>;
  findByUser(userId: ID): Promise<readonly Order[]>;
  findByCategoryType(categoryType: string): Promise<readonly Order[]>;
}

// order-receipt-service.ts

export interface OrderRecipeService {
  generateReceipt(order: Order): Promise<Receipt>;
}

// ... etc

The first doubt comes here with the naming, how should we name the interfaces now properly to emphasize the grouping? Alternatively, we might also move every method into its own interface like:

// find-orders-by-product-service.ts

export interface FindOrdersByProductService {
  findByProduct(productId: ID): Promise<readonly Order[]>;
}

// find-orders-by-user-service.ts

export interface FindOrdersByUserService {
  findByUser(userId: ID): Promise<readonly Order[]>;
}

// ... etc

Hmm, seems very verbose now...

What if we could break up the service interface completely to remove the coupling?

Hopefully, unlike languages like C#, TypeScript is functional, meaning we can simply break up the interface to function types, so OrderService becomes:

Note: In C# we can use delegate for it.

// find-order-by-id.ts

export type FindOrderById = (orderId: ID) => Promise<Order>;

// ... others...

// generate-report.ts

export type GenerateReceipt = (order: Order) => Promise<Receipt>;

Looks much cleaner now. Besides, it makes the API simpler, it also makes the API client constructor more understandable:

// order-receipt-generator.ts

export class OrderReceiptGenerator {
  constructor(
    private readonly findOrderById: FindOrderById,
    private readonly generateReceipt: GenerateReceipt
  ) {}

  public generate(orderId: ID) {
    const order = this.findOrderById(orderId);

    return this.generateReceipt(order);
  }
}

Additionally, it solves another problem with understanding of OrderReceiptGenerator. In the classical (service injection) approach, it's not possible to know what methods of OrderService are going to be called in OrderReceiptGenerator without looking into each method code. However, with the functional injection approach, we can just check the OrderReceiptGenerator constructor dependencies to easily grasp.

Additionally, we can now see if OrderReceiptGenerator class becomes too complex - when it has too many dependencies.

Adding function to the Registry

To add a function to the Registry we can use the following code snippet

// stub-find-order-by-id.ts

export class StubFindOrderById {
  findOrderById: FindOrderById = (id) => {
    return Promise.resolve({
      id,
    });
  };
}

// registry/stub-find-order-by-id.ts

export function stubFindOrderById() {
  return () => {
    const { findOrderById } = new StubFindOrderById();

    return {
      findOrderById,
    };
  };
}

We can go even further now, and move Fastify server creation to the RegistryComposer as well:

// fastify-server.ts
export function fastifyServer() {
  return (deps: Parameters<typeof ordersRoutes>[0]) => {
    const server = fastify({});

    server.register(ordersRoutes(deps));

    return {
      fastifyServer: server,
    };
  };
}

// create-app-registry.ts
export function createAppRegistry() {
  return new RegistryComposer()
    .add(orderService())
    .add(orderReceiptGenerator())
    .add(fastifyServer())
    .compose();
}

// index.ts
async function main() {
  const { fastifyServer } = createAppRegistry();

  try {
    await fastifyServer.listen({ port: PORT });

    console.log("listening on port", PORT);
  } catch (err) {
    fastifyServer.log.error(err);
    process.exit(1);
  }
}

main();

Conclusion

In this article, we learned how to use Dependency Injection with Registry on TypeScript. The approach we implemented has the following characteristics:

  • typesafe, meaning all the dependencies are resolved at developing/transpile time
  • no framework/library dependencies to implement the injection (Pure DI)
  • Easy to know the dependency lifetime since all dependencies are "Singleton" like in Registry
  • Easy to create multiple instances of the same service with different token
  • Easy to chain dependencies with the same token (for ex. for implementing middleware pattern)
  • Zero-cost overhead (almost)

In the following articles, I'm going to cover the topics of scoped-like dependencies, logging, configuration, benchmarks, and using the registry within hexagonal/clean architecture projects.

Sample code can be found in the repository.