Creating your own Dependency Injector (DI) library – Typescript
We will start by defining what is the DI pattern and what is a good use-case for such a pattern. The main goal of this pattern is disconnecting the client from the knowledge of how to construct a service or an object it needs and letting someone else handle the construction (aka - Injector).

When you create an object within your class aka "newing it yourself" you bind your class to these objects making it impossible to switch implementations from outside of your code example - using configuration files (such as in Java beans and XML), meaning you will need to compile/deploy/build your codebase each time a change is needed. which means your class is not reusable since it is bound to a single implementation of that service/object.
This pattern can help solve answer some important questions/ goals such as:
- How can an application or class be independent of how its objects are constructed?
- Objects creation can now be specified in separate configuration files.
- How can our application support different configurations?
- Dependency injection makes testing easier - since now we can change the service to a mocker of the real service so we can control the results.
This pattern can help solve answer some important questions/goals such as:
- How can an application or class be independent of how its objects are constructed?
- Objects creation can now be specified in separate configuration files.
- How can our application support different configurations?
- Dependency injection makes testing easier - since now we can change the service to a mocker of the real service so we can control the results.
Check out Why do we have Dependency Injection in web development for in-depth explanation of the need for DI.
Typescript is great for implementing this design pattern and in this post, we will implement a simple DI system step by step. We will start by defining a constructible interface:
export interface Constructable<T=any> {
new(...params): T;
}
As you may notice it has the new method on it and receives an unknown number of parameters constructing a new object of type T (generics). In javascript, an object can be constructed by applying the keyword "new" in front of a function call. This function plays a special role and is what is known as a constructor function. When you will run with the new keyword javascript will create a new object called "this" and attach it to the function scope. This will be the returned object.
Here is a simple example of an Animal function which will return a new animal with a name:
function Animal(name) {
this.name = name
}
const test1 = Animal('my name');
console.log(test1) // undefined
Calling the function Animal as a regular function without the "new" keyword in front results in the undefined return value. Question: where did the "this.name" go? we didn't receive any errors in this code. Try and run this code:
console.log(window.name); // 'my name'
Since the JS engine didn't know what was the "this" object in the function context/scope it went up the scope tree until it found one which in our case is the window object as the requested "this" in the base scope or the global scope. Now let's apply the function using the new keyword in front:
const test2 = new Animal('hello');
console.log(test2);
// Animal {name: "name"}
// name: "name"
// __proto__: Object
console.log(test2.name); // hello
This time we got a result and we can even notice that the test2.name
has the correct value. Now we are not polluting the window object or global scope with the name attribute. let's dig a little in the __proto__
object and try to understand it:
__proto__:
constructor: ƒ Animal(name)
__proto__: Object
We can see that it has a constructor function called Animal which means that the function that constructed this object was the Animal function when it was applied with the new keyword in front. It also has another __proto__ which is the Object base object. Great, with this common knowledge we can start moving forward to the actual implementation of our ID system. Next, we move to the way we will declare objects that can be injected using our system. we can leverage typescript power and use a decorator. This decorator will be placed above our service class declaration such as Injectable
:
export function Injectable(constructor: Constructable): Constructable {
return constructor;
}
@Injectable
export class ServiceClass{
constructor() {
}
sayHello(){
return 'Hello from ServiceClass';
}
}
Typescript Decorators
Decorators provide a way to add both annotations and a meta-programming syntax for class declarations and members (methods and properties). Decorators are a stage 2 proposal for JavaScript and are available as an experimental feature of TypeScript.
In order to enable decorators in your typescript project,, you will need to add the following to your tsconfig.json file:
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
// tsconfig-schema.json file:
"experimentalDecorators": {
"description": "Enables experimental support for ES7 decorators.",
"type": "boolean"
},
"emitDecoratorMetadata": {
"description": "Emit design-type metadata for decorated declarations in source.",
"type": "boolean"
},
For now, let's just think about decorators a higher-level function which means a function that receives a function as a parameter or/and one that returns a function as a return value. Be aware that you can only decorate classes in typescript and not plain functions. We will use at first a Class decorator (there are also method decorators and parameter decorators).
Class Decorator
A Class Decorator is declared just before a class declaration. The class decorator is applied to the constructor of the class and can be used to observe, modify, or replace a class definition. A class decorator cannot be used in a declaration file, or in any other ambient context (such as on a declare
class).
The expression for the class decorator will be called as a function at runtime, with the constructor of the decorated class as its only argument. (https://www.typescriptlang.org/docs/handbook/decorators.html#class-decorators).
export function Injectable(constructor: Constructable): Constructable {
const original = constructor;
return original;
}
This is a basic decorator function of a class - it receives a Constructible function aka a class as we discussed above currently, we are not changing the behavior of the class or constructor function and just returning it as is. This is mainly for the reason we would like typescript to add its metadata data to the class so we will be able to reflect it at run time.
Reflection
Reflection is used to describe code that is able to inspect other code in the same system (or itself) or Reflection
is a language's ability to inspect and dynamically call classes, methods, attributes, etc. at runtime. TypeScript includes experimental support for emitting certain types of metadata for declarations that have decorators., we will use the assistance of the "reflect-metadata
" library to leverage this power.
Now that we understand what is reflection let's harness the power it provides if we have a reference to a constructor function we can try and investigate it using reflection:
const metaData: Constructable[] = Reflect.getMetadata('design:paramtypes', constructor);
design:paramtypes
- will tell us what are the types of the parameters our constructor receives when it is invoked - now that we know what we need we can invoke them using the same technique - recursion can be a great fit here since each one of these parameters can be construable that needs to be constructed with its own params.
private constructObject(constructor: Constructable) {
const metaData: Constructable[] = Reflect.getMetadata('design:paramtypes', constructor);
// We need to init each constructor function into it's instance
const argumentsInstances = metaData.map((params) => this.constructObject(params));
currentInstance = new constructor(... argumentsInstances);
return currentInstance;
}
We map all our parameters recursively using the same function - constructObject. now that we have all the params initialized we can invoke our original constructor and that's what we do. This is still expensive and wasteful since we can be invoking the same parameters over and over so let's use a simple cache mechanism:
export class Injector {
private diMap = new Map();
getInstance<T>(contr: Constructable<T>) : T {
const instance = this.constructObject(contr);
return instance;
}
private constructObject(constructor: Constructable) {
let currentInstance = this.diMap.get(constructor)
if (currentInstance) return currentInstance;
const metaData: Constructable[] = Reflect.getMetadata('design:paramtypes', constructor);
// We need to init each constructor function into it's instance
const argumentsInstances = metaData.map((params) => this.constructObject(params));
currentInstance = new constructor(... argumentsInstances);
this.diMap.setKey(constructor, currentInstance);
return currentInstance;
}
}
And there we have it, a simple yet elegant solution let's use it with some tests - I created for the tests a few classes with none to a few nested dependencies and test it out using the jest framework.
import { Injectable } from '../decorators';
import { Injector } from '../injector';
const readableMessage = 'Hello from MockClassNoArgs';
@Injectable
export class MockClassNoArgs{
constructor() {
}
sayHello(){
return 'Hello from MockClassNoArgs';
}
}
@Injectable
export class MockClass1Args{
constructor(public mock: MockClassNoArgs) {
}
}
@Injectable
class MockClassDeepArgs{
constructor(public mock: MockClassNoArgs, public mock2: MockClass1Args) {
}
}
describe('Di should work', () => {
const injector = new Injector();
beforeEach(() => {
});
afterEach(() => {
});
test('it should inject a not null object', () => {
let injected = injector.getInstance<MockClassNoArgs>(MockClassNoArgs);
expect(injected).not.toBe(undefined);
});
test('it should inject a not null object and use methods on it', () => {
let injected = injector.getInstance(MockClassNoArgs);
const hello = injected.sayHello();
expect(hello).toBe(readableMessage);
});
test('it should be able to inject a dependable class', () => {
let injected = injector.getInstance(MockClass1Args);
const hello = injected.mock.sayHello();
expect(hello).toBe(readableMessage);
});
test('it should be able to inject a dependable class with a dependable class (2 levels down)', () => {
let injected = injector.getInstance(MockClassDeepArgs);
const hello = injected.mock.sayHello();
const hello2 = injected.mock2.mock.sayHello();
expect(hello).toBe(readableMessage);
expect(hello2).toBe(readableMessage);
});
})
What we have here is basically an injector all we need to do is ask the type we are requesting and we shall receive an initialized object.
The final step for our convince is to inject the instances of the constructor to the first class we are using again using the injector construct function for the first time. this we can do with yet again decorators this time we can use the param decorator and replace the requested item type with an actual instance.