This is the third article in the "Metaprogramming in JavaScript and TypeScript" series.
First two:
- Metaprogramming JavaScript / TypeScript Part #1: descriptors.
- Metaprogramming in JavaScript/TypeScript Part #2 (Decorators)
In this article, we will consider such a concept as dependency injection.
And in this example, we will try to understand the principle of Metaprogramming and get acquainted with the reflect-metadata library.
Let's start right away with an example:
Suppose we have three classes:
1) MoviesService — should return information about movies to us, preferably with comments.
2) CommentsService — works with comments.
3) CrudService — a standard service for working with API services
/** | |
* CRUD Service for CRUD operations against BE / DB | |
*/ | |
class CrudService { | |
getData(entity: string) { | |
return `Some Data from -> ${entity}`; | |
} | |
} | |
/** | |
* Service to retrieve/crate/update comments | |
*/ | |
class CommentsService { | |
constructor(public crudService: CrudService) {} | |
getComments() { | |
return this.crudService.getData('/comments'); | |
} | |
} | |
/** | |
* Service to retrieve/crate/update comments | |
*/ | |
class MoviesService { | |
constructor( | |
private commentsService: CommentsService, | |
private crudService: CrudService | |
) {} | |
getMovies() { | |
return this.crudService.getData('/movies'); | |
} | |
getComments() { | |
return this.commentsService.getComments(); | |
} | |
} |
The initialization of the MoviesService class would look like this:
const movies = new MoviesService( | |
new CommentsService(new CrudService()), | |
new CrudService() | |
); |
And it seems everything looks logical, but it's not good enough :).
Indeed, in the constructors of each one of the classes, I have already indicated which dependencies are necessary to initialize this class … so why do we need this again during initialization?
And what if, for unit testing, we need to substitute a certain CrudServiceStub instead of CrudService? There is also the issue of CrudService being SingleTone and not being initialized twice (MoviesService and CommentsService). All this, of course, can be solved in the old ways, but we are not here for this :)
Let's start with decorators.
Simple and even empty decorator
export const Injectable = (): GenericClassDecorator<Type<object>> => { | |
return (target: Type<object>) => { | |
// console.log( | |
// '[Injectable]', | |
// target.name, | |
// Reflect.getMetadata('design:paramtypes', target) | |
// ); | |
}; | |
}; |
Since the decorator is a regular function, after compilation into JS, we will get:
export const Injectable = () => { | |
return (target) => { | |
// console.log( | |
// '[Injectable]', | |
// target.name, | |
// Reflect.getMetadata('design:paramtypes', target) | |
// ); | |
}; | |
}; |
But if we use this as decorator…
export const Injectable = (): GenericClassDecorator<Type<object>> => { | |
return (target: Type<object>) => { | |
// console.log( | |
// '[Injectable]', | |
// target.name, | |
// Reflect.getMetadata('design:paramtypes', target) | |
// ); | |
}; | |
}; | |
@Injectable() | |
class CrudService { | |
getData(entity: string) { | |
return `Some Data from -> ${entity}`; | |
} | |
} |
Now JS will look completely different:
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { | |
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; | |
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); | |
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; | |
return c > 3 && r && Object.defineProperty(target, key, r), r; | |
}; | |
export const Injectable = () => { | |
return (target) => { | |
// console.log( | |
// '[Injectable]', | |
// target.name, | |
// Reflect.getMetadata('design:paramtypes', target) | |
// ); | |
}; | |
}; | |
let CrudService = class CrudService { | |
getData(entity) { | |
return `Some Data from -> ${entity}`; | |
} | |
}; | |
CrudService = __decorate([ | |
Injectable() | |
], CrudService); |
We are interested in the __decorate functionOr rather the line:
Reflect.decorate(decorators, target, key, desc);
In order to understand what it does, let's add a console.log. To our decorator.
And the decorator itself will also be added to the CommentsService class
export const Injectable = (): GenericClassDecorator<Type<object>> => { | |
return (target: Type<object>) => { | |
console.log( | |
'[Injectable]', | |
target.name, | |
Reflect.getMetadata('design:paramtypes', target) | |
); | |
}; | |
}; | |
@Injectable() | |
class CrudService { | |
getData(entity: string) { | |
return `Some Data from -> ${entity}`; | |
} | |
} | |
@Injectable() | |
class CommentsService { | |
constructor(public crudService: CrudService) {} | |
getComments() { | |
return this.crudService.getData('/comments'); | |
} | |
} | |
//LOG: [Injectable] CrudService undefined | |
//LOG: [Injectable] CommentsService ▶[ƒ CrudService()] | |
And so, we see that Reflect.getMetadata(‘design:paramtypes’, target)returns a list of dependencies of the class being decorated. Where this information comes from, it is better to read in the original source.
To simplify, TypeScript will automatically add the metadata to Reflect (store/object).
Let's try to decorate the class method
export const Validate = () => { | |
return (target, key, descriptor) => { | |
console.log( | |
'[Validate]', | |
target, | |
Reflect.getMetadata('design:paramtypes', target, key) | |
// Here we need to pass the decorated property name (key = getData) to get relevant metadata from Reflect.object | |
); | |
console.log( | |
'[Validate] returntype', | |
target, | |
Reflect.getMetadata('design:returntype', target, key) | |
); | |
}; | |
}; | |
@Injectable() | |
class CrudService { | |
@Validate() | |
getData(entity: string): string{ | |
return `Some Data from -> ${entity}`; | |
} | |
} | |
// LOG: [Validate] paramtypes [ƒ String()] | |
// LOG: [Validate] returntype ƒ String() |
In other words, Reflect is an object that stores some data. What data? The metadata. TypeScript in this object adds information about the types in the signatures of the class and methods and the types that these methods return.
Knowing all this and having access to the Reflect object, we can write the following function:
export const resolve = (target: Type<any>) => { | |
// Get list of dependent classes | |
let tokens = Reflect.getMetadata('design:paramtypes', target) || []; | |
// Create dependent instances recursivly | |
let injections = tokens.map((token) => resolve(token)); | |
// Return class instance with dependets instances. | |
return new target(...injections); | |
}; | |
const testMoviesR: MoviesService = resolve(MoviesService); | |
console.log(testMoviesR.getComments()); | |
// LOG: Some Data from -> /comments | |
console.log(testMoviesR.getMovies()); | |
// LOG: Some Data from -> /movies |
All dependencies will be automatically resolved.
However, this is still not ideal — for example, CrudService it will be created twice. But at the beginning of the article, we talked about singleton.
To begin with, for a more straightforward implementation, instead of the resolve function, we will create an Injector and add the necessary functionality to it.
export const Injector = new (class { | |
instMap: Record<string, any> = {}; | |
resolve<T>(target: Type<any>): T { | |
let tokens = Reflect.getMetadata('design:paramtypes', target) || []; | |
let injections = tokens.map((token) => { | |
return Injector.resolve<any>(token); | |
}); | |
if (this.instMap.hasOwnProperty(target.name)) { | |
return this.instMap[target.name]; | |
} else { | |
this.instMap[target.name] = new target(...injections); | |
return this.instMap[target.name]; | |
} | |
} | |
})(); | |
const testMovies = Injector.resolve<MoviesService>(MoviesService); | |
This may not be the most elegant solution, but it will work as an example :)))
Summing up:
I hope I managed to talk about the following points in this article:
Reflect on a class is a kind of object into which TypeScript adds information about the types in the constructor, the types of method signatures, and the types that these methods return when compiling.
With the help of the reflect-metadata library, we can get this information and use it for our purposes.
In the following article, we will analyze how to add the information we need to the Reflect object and how we can use it.
If you like this article, comment and react ♾️ times.
Follow me on Twitter, Medium Dev/To for blog updates.
Check out my website and Youtube for videos and public talks.
Feel free to ask a question.
Thanks a lot for reading!
Top comments (0)