DEV Community

glutio
glutio

Posted on • Edited on

IoC Dependency Injection in TypeScript Without Libraries

Hi All,

I wanted to use the IoC Dependency Injection (DI) pattern on a little project I am working on in TypeScript, and I did not want to use any libraries, just plain TypeScript. I came up with this pattern below, which is not ideal, but gets close to a proper IoC DI paradigm I think.

For the sake of the explanation, let's assume we have two interfaces IFoo and IBar and the corresponding implementations CFoo and CBar. We also have CApp as the main class. Let's assume CApp depends on IFoo and CFoo depends on IBar. We want to recursively instantiate all dependencies starting from the CApp class. Below is what the ideal syntax would look like:

interface IBar {
  bar();
}

interface IFoo {
  foo();
}

class CBar implements IBar {
  bar() {
    console.log("Hello, world");
  }
}

class CFoo implements IFoo {
  bar: IBar;
  constructor(bar: IBar) {
    this.bar = bar;
  }  
  foo() { 
    this.bar.bar();
  }
}

class CApp {
  foo: IFoo;
  constructor(foo: IFoo) {
    this.foo = foo;
  }
}

const app = ioc.resolve(CApp);
Enter fullscreen mode Exit fullscreen mode

Unfortunately, this exact syntax is not possible, because IFoo and IBar types do not exist at runtime. My alternative is to use a kind of type ID with syntactic sugar.

interface IBar {
  bar();
}
const IBar = {} as IBar; // type ID

class CFoo implements IFoo {
  static CFoo = { // static object
    bar: IBar // bar is assigned the value of `const IBar`
  };

  bar: IBar;
  constructor(deps: typeof CFoo.CFoo) {
    this.bar = deps.bar;
  }
}
Enter fullscreen mode Exit fullscreen mode

What we did here is we declared a type ID constant with the same name as the type. The constant is a reference to some unique object cast to the type it represents. In the class where dependencies are to be injected, we declared a static object of the same name as the class, so we can dynamically look it up. The object's declaration colon (:) syntax conveniently makes it look like a type, while in reality, this is an assignment of the type ID value. This static object is essentially a mini reflection metadata without a reflection library. We can use typeof to infer the type of the object that TypeScript will enforce when you call the constructor.

With this setup, the IoC container looks like this:

class IoC {
  di = new Map<object, Function>();    

  constructor() {
    this.di.set(IFoo, () => this.resolve(CFoo));
    this.di.set(IBar, () => this.resolve(CBar));
  }

  resolve<T>(ctor: new (arg?: {}) => T): T {
    const className = ctor.name;
    const deps = (ctor as any)[className];

    if (deps) {
      const resolvedDeps = Object.fromEntries(
        Object.entries(deps).map(
          ([key, typeId]) => [key, this.di.get(typeId as object)()]
        )
      );
      return new ctor(resolvedDeps);
    }        

    return new ctor();
  }
}

const ioc = new IoC();
Enter fullscreen mode Exit fullscreen mode

The dependency resolvers are defined directly in the constructor of the IoC. The resolve<T>() method takes a class as an argument, determines the name of the static property which lists the dependencies, and resolves them by calling the factory function in the resolvers map. The factory function, in turn, recursively resolves its own type, in case it also has dependencies.

With this syntax in place, and the implementation of the IoC container, all you need to do to bootstrap your app is

const app = ioc.resolve(CApp);
Enter fullscreen mode Exit fullscreen mode

Happy coding!

AWS GenAI LIVE image

How is generative AI increasing efficiency?

Join AWS GenAI LIVE! to find out how gen AI is reshaping productivity, streamlining processes, and driving innovation.

Learn more

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs