On my journey through the depths of Typescript and its frameworks I sometimes stumbled upon a technique that is common in more traditional languages like Java and C# (and has even found its way into Go with the superb Fx library), but not often used in JavaScript: Metadata Reflection.
While the theory behind it always seemed quite intimidating in other languages (mostly because I’m not as fluent in them), I was intrigued to explore it in JS because of its relaxed typing system. So I decided to learn some of the basics by implementing my very own small scale dependency injection library! And I will take you with me 🙃
Initialize the project
First we want to create a new project with npm, install our dependencies and initialize Typescript in it.
npm i typescript reflect-metadata && npx tsc --init
npm i typescript
and npx typescript --init
should be pretty straightforward: installing Typescript and creating a sample tsconfig.json
file. we will take a look at what reflect-metadata
does in a second, but first we want to finish setting up Typescript. I don’t really like the bloated sample, so we are going to replace the content with the following:
//tsconfig.json
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": ["DOM", "ES5", "ScriptHost", "ES2015.Collection"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"downlevelIterator": true,
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"experimentalDecoratorMetadata": true,
"moduleResolution": "node",
"skipLibCheck": true
}
}
Most of it is self explanatory, our source code will be in ./src
and the build output will be in ./dist
. The two interesting settings are the two experimental ones: experimentalDecorators
and experimentalDecoratorMetadata
.
Some theory
experimentalDecorators
is just the language support for the decorator:
@doSomething()
class Test {}
experimentalDecoratorMetadata
works together with the reflect-metadata
package we installed earlier to add metadata to classes and its functions and parameters.
As I said, most high level languages have a language for metadata reflection. This provides the code with information about types at runtime rather than during compile time, where for example variable assignments are checked for type safety. The code is able to inspect its own structure. In strongly typed languages this helps to support unknown objects1, like the response from an API thats supposed to change in the future.
Reflection for JavaScript is currently a stage 3 proposal2, that why we are using reflect-metadata
: its a shim that fills the functionality until it is accepted into the core language.
Getting to work
Enough of the (very short) theory behind reflection and metadata and onto more practical things: one use case for reflection is dependency injection. What are we trying to build? Dependency injection consists of a container that we can use to fill the parameters in the constructor of a dependent class from a single source. So instead of for example creating a new instance of a service class every time a web controller receives a request and is instantiated, we can reuse that class and even share the instance of the service across several controllers! The end result will look and work like this:
class BaseClass {
print() {
console.log("Hello Guys");
}
}
@Inject()
class DependentClass {
constructor(private base: BaseClass) {}
print() {
this.base.print();
}
}
let resolvedClass = di<DependentClass>(DependentClass);
resolvedClass.print();
// -> Hello Guys
You see in the example that all we did was give the dependency resolver (the di()
function) an entry class and yet we could still use the dependency without ever writing let base = new BaseClass()
anywhere.
SO how does this work?
The container
The heart of a dependency injection service is the container. In here we register all the different types and keep them in a single source of truth: instead of recreating dependencies every time we take them from the container. To implement it we use a Map
3 as our base type and add a function to resolve dependencies.
export class Container extends Map {
public resolve<T>(target: Type<any>): T {
const tokens = Reflect.getMetadata("design:paramtypes", target) || [];
const injections = tokens.map((token: Type<any>) =>
this.resolve<any>(token)
);
const classInstance = this.get(target);
if (classInstance) {
return classInstance;
}
const newClassInstance = new target(...injections);
this.set(target, newClassInstance);
return newClassInstance;
}
}
Before we start to look at what the container does, we need to define the type that we used for the argument to the resolve
function. Our dependency injection only targets classes and in order to make sure that we can create a new instance of the argument:
export interface Type<T> {
new (...args: any[]): T;
}
The interface limits the parameter to an object that can be constructed with the new
keyword and takes an arbitrary amount of parameters itself.
Now we can use reflection on the target.
const tokens = Reflect.getMetadata("design:paramtypes", target) || [];
Here we use the metadata that is emitted from typescript to get the types of all the parameters the target object takes in its constructor. Reflection allows us to get three design keys at the moment4:
design:type
, which gets the type of the annotated fielddesign:paramtypes
, which gets the parameters of the target classdesign:returntype
, which shows us the return of a function
const injections = tokens.map((token: Type<any>) => this.resolve<any>(token));
const classInstance = this.get(target);
if (classInstance) {
return classInstance;
}
const newClassInstance = new target(...injections);
this.set(target, newClassInstance);
return newClassInstance;
Now we can just go over the tokens we got from the reflection and recursively make sure that their dependencies are also resolved. If the class type that we want to build is already present, we just grab it from the map. If not we create a new instance and pass the resolved dependencies.
Adding the decorator
Thats the hard part done! No all we need is a decorator that we can use to annotate the classes that we want our injector to be able to resolve.
export const Inject = (): ((target: Type<any>) => void) => {
return (target: Type<any>) => {};
};
This one almost seems too easy. We are not really doing anything here with the decorator, we are just using it for the metadata. So now any class that is decorated with @Inject()
can be resolved!
Finishing up
Now to use what we’ve built, we just need a little function that acts as an entry point to our project where we can pass the base class of our dependency tree.
export const di = <T>(target: Type<any>): T => {
const injector = new Injector();
const entryClass = injector.resolve<T>(target);
return entryClass;
};
This builds a new container and resolves the base class that we passed as an argument. Et voila, we are finished! We can use this rudimentary example as it is or we move on and create some more advanced features like scoping, property resolution and so on. I might write another post on that in the future so check back from time to time 😇