DEV Community

Kiran Mantha
Kiran Mantha

Posted on

Dependency Injection in VanillaJS using WeakMap

That's how DI works
You do come across the term Dependency Injection alot thanks to modern frameworks like angular. In most of implementations the devs make use of reflection to get the parameter types. But the reflect-metadata npm package itself weigh ~300kb without any single line of external code. Moreover reflection won't work in case of minification. With all these problems on board let's implement a simple DI using built-in javascript features without a single external dependency.

Enough talk let's start with writing some code.

Typically in modern day framework terminologies, a dependency is named as either Provider or Service. For the sake of simplicity let us name our fucntion as Service.

//service.js
const Service = (klass) => {
  const instance = new klass();
  console.log(instance);
}
export { Service };
Enter fullscreen mode Exit fullscreen mode

easy peasy. when we execute this as follows:

// index.js
import { Service } from './service';

class ServiceA {
  greet() {
    console.log('hello world');
  }
}

Service(ServiceA);
Enter fullscreen mode Exit fullscreen mode

It prints instance of ServiceA. Nothing fancy.

Now the real stuff comes to picture.

Where we need to save this instance to access it later??

Let's see the possibilities:

  1. using class names as identifiers. This works but it will not work after minified. so you have to sacrifice bundle size.
  2. using strings as identifiers to save instance using Map(). Well this will work out but it ends up too much coding. And also Map preserve the object even though nothing consumes it.

Then what's the solution??

Welcome to WeakMap

yes we leverage weakmap to save the instances. Ok then lets start this by creating an Injector class with register, getService and clear methods.

// injector.js
const Injector = new class {
  register() {}

  getService() {}

  clear() {}
}
export { Injector };
Enter fullscreen mode Exit fullscreen mode

awesome. let's import this in our service file.

//service.js
import { Injector } from './injector';

const Service = (klass) => {
  const instance = new klass();
  console.log(instance);
  Injector.register();
}
export { Service };
Enter fullscreen mode Exit fullscreen mode

huff our injector is not saving the instance. Let's implement the register method.

// injector.js
const Injector = new class {
  #weakmap = new WeakMap()
  register(klass, instance) {
    if(!this.#weakmap.has(klass)) {
      this.#weakmap.set(klass, instance);
    }
  }

  getService() {}

  clear() {}
}
export { Injector };
Enter fullscreen mode Exit fullscreen mode

Okay. let me explain it. We all know a WeakMap() need an object as key unlike Map(). So the key here is the blueprint of the class and value is instance of that class. neat right?? see how we avoid using names to save the instance?? cool right 😎

ok now let's come back to service file.

//service.js
import { Injector } from './injector';

const Service = (klass) => {
  const instance = new klass();
  Injector.register(klass, instance);
}
export { Service };
Enter fullscreen mode Exit fullscreen mode

Great. then where is the dependency injection. woahh hold on.. i'm coming to that part.

Let's create two classes where one depends on other.

// index.js
class ServiceA {
   getGreeting() {
     return 'hello world';
   }
}

class ServiceB {
  serviceA;
  constructor(_serviceA) {
     this.serviceA = _serviceA;
  }

  greet() {
    return this.serviceA.getGreeting();
  }
}

Service(ServiceA);
Service(ServiceB);
Enter fullscreen mode Exit fullscreen mode

If you execute the above logic and try to log serviceB.greet() you will get an error saying serviceA is undefined.

Yes that's expected. Then how to supply the ServiceA as dependency. let's revisit our service implementation.

//service.js
import { Injector } from './injector';

const DEFAULT_SERVICE_OPTIONS = {
   deps: []
}

const Service = (...args) => {
  let options = DEFAULT_SERVICE_OPTIONS;
  let klass;
  if(args[0].hasOwnProperty('deps')) {
     options = args[0];
     klass = args[1];
  } else {
     klass = args[0];
  }
  const instance = new klass();
  Injector.register(klass, instance);
}
export { Service };
Enter fullscreen mode Exit fullscreen mode

That's a mouthful. Ok what we're trying to do is checking the arguments of Service implementation so that we can use it as

  1. Service(someClassA) if no dependencies are mentioned.
  2. Service({ deps: [someClassA] }, someClassB) if dependencies are mentioned.

If you observe the 2nd use case, we're actually passing the blueprint of someClassA itself not a string or not using class.name. You remember, above we're saving the instance using blueprint of a class. That's the reason we're passing blueprints to deps.

Okay great. we got dependencies but we have to get their instances before creating the instance of target class. In above injector.js file we left getService method as blank. let's fill it with some nonsense.

// injector.js
const Injector = new class {
  #weakmap = new WeakMap()
  register(klass, instance) {
    if(!this.#weakmap.has(klass)) {
      this.#weakmap.set(klass, instance);
    }
  }

  getService(klass) {
    return this.#weakmap.get(klass);
  }

  clear() {}
}
export { Injector };
Enter fullscreen mode Exit fullscreen mode

See as we discussed, we're passing class blueprint to injector to fetch its instance. now come back to service.js

//service.js
import { Injector } from './injector';

const DEFAULT_SERVICE_OPTIONS = {
   deps: []
}

const Service = (...args) => {
  let options = DEFAULT_SERVICE_OPTIONS,
      klass,
      instance;
  const dependencies = [];

  if(args[0].hasOwnProperty('deps')) {
     options = args[0];
     klass = args[1];
  } else {
     klass = args[0];
  }

  // loop all the deps if any and get their instance from Injector.
  // else simply create new instance.
  if(options.deps.length) {
      for(const dependency of options.deps) {
          dependencies.push(Injector.getService(dependency));
      }
      instance = new klass(...dependencies);
  } else {
    instance = new klass();
  }
  Injector.register(klass, instance);
}
export { Service };
Enter fullscreen mode Exit fullscreen mode

in our service.js file we're looping through all the dependencies we're passing and getting their instances from Injector. Now let's see index.js

// index.js
import { Service } from './service';
import { Injector } from './injector';

class ServiceA {
   getGreeting() {
     return 'hello world';
   }
}

class ServiceB {
  serviceA;
  constructor(_serviceA) {
     this.serviceA = _serviceA;
  }

  greet() {
    return this.serviceA.getGreeting();
  }
}

Service(ServiceA);
Service({ deps: [ServiceA] }, ServiceB);
console.log(Injector.getService(ServiceB).greet());
Enter fullscreen mode Exit fullscreen mode

did you observe the last line before console?? right. as we discussed above, we're passing ServiceA blueprint as dependency to ServiceB. Now when we execute this we'll see 'hello world' message from ServiceB which we kept in ServiceA.

Now as an addon we can clear the all the service instances in Injector using clear method.

// injector.js
const Injector = new class {
  #weakmap = new WeakMap()
  register(klass, instance) {
    if(!this.#weakmap.has(klass)) {
      this.#weakmap.set(klass, instance);
    }
  }

  getService(klass) {
    return this.#weakmap.get(klass);
  }

  clear() {
    this.#weakmap = new WeakMap();
  }
}
export { Injector };
Enter fullscreen mode Exit fullscreen mode

So that's it. we neither used any external library for dependency injection nor used reflection and nor used any strings as identifiers. Damn clean right?? You can improve this even further.

Here is the below implementation of DI:

In typescript, you can read the type of constructor parameters using reflection. but it won't play nice with new bundlers like esbuild, vite, parcel etc.. because they won't preserve this metadata and moreover they mangle all the property names / class names. so relying on metadata / class names is not advisable.

Hope this helps you in creating your own DI in VanillaJS. VanillaJS has a lot of features which we seldom use. With the advent of more browsers bringing latest ECMAScript features, frameworks are becoming more redundant. Employing typescript with same logic will give you the typechecking prowess.

This implementation will work irrespective of unminified / minified version. so you can use this safely with any of existing bundlers. And probably this is the best usecase on how to use WeakMap in javascript(well as far i came to know. Bring me more examples 😉).

I personally used the above DI logic in my own framework PlumeJS which is built on webcomponents and typescript. See if you can contribute to it 😉

Hope you enjoyed this.. 😊
See you in next article..
Kiran 👋

Discussion (0)