The Origin of the Problem
Both NestJS and MidwayJS use Dependency Injection (DI) and Inversion of Control (IoC) to enable OOP programming, and they almost all implement this through decorators and metadata. This confuses many people, so let me use a common NestJS example:
@Injectable()
class CatService {
constructor(public readonly animalService: AnimalService) {}
run(){
this.animalService.run()
}
}Have you ever wondered how animalService gets automatically instantiated as AnimalService? And what about controllers:
@Controller('cats')
export class CatsController {
constructor(public readonly catService: CatService) {}
@Get('/run')
run(){
this.catService.run()
}
}Can you guess how @Controller and @Get decorators map the CatsController's run method to the "/cats/run" route?
Fundamentals
Class Decorators
Let's use a code snippet from Ruan Yifeng:
@testable
class MyTestableClass {
// ...
}
function testable(target) {
target.isTestable = true;
}
MyTestableClass.isTestable // trueAs we can see, the decorator function receives the class itself through its target parameter.
Method Decorators
Let's review method decorators with another example:
class Math {
@log
add(a, b) {
return a + b;
}
}
function log(target, name, descriptor) {
var oldValue = descriptor.value;
descriptor.value = function() {
console.log(`Calling ${name} with`, arguments);
return oldValue.apply(this, arguments);
};
return descriptor;
}
const math = new Math();
// passed parameters should get logged now
math.add(2, 4);As shown above, a method decorator can completely redefine the decorated method's content. It can access the method itself through the third parameter descriptor.
reflect-metadata
Metadata is an ES7 proposal primarily used to add and read metadata at declaration time. TypeScript 1.5+ already supports it. We can install it via npm:
npm i reflect-metadata --saveThen configure the emitDecoratorMetadata option in tsconfig.json:
{
"compilerOptions": {
"emitDecoratorMetadata": true
}
}Let's look at the basic usage of reflect-metadata:
@Reflect.metadata('inClass', 'A')
class Test {
@Reflect.metadata('inMethod', 'B')
public hello(): string {
return 'hello world';
}
}
console.log(Reflect.getMetadata('inClass', Test)); // 'A'
console.log(Reflect.getMetadata('inMethod', new Test(), 'hello')); // 'B'When used as a decorator, Reflect.metadata can add metadata to both classes and methods. Most importantly, it can retrieve function parameter types via Reflect.getMetadata("design:paramtypes", target, key).
Custom Keys
In addition to getting type information, reflect-metadata can also store data with custom keys:
function classDecorator(): ClassDecorator {
return target => {
// Define metadata on the class with key 'classMetaData' and value 'a'
Reflect.defineMetadata('classMetaData', 'a', target);
};
}
function methodDecorator(): MethodDecorator {
return (target, key, descriptor) => {
// Define metadata on the class prototype property 'someMethod' with key 'methodMetaData' and value 'b'
Reflect.defineMetadata('methodMetaData', 'b', target, key);
};
}
@classDecorator()
class SomeClass {
@methodDecorator()
someMethod() {}
}
Reflect.getMetadata('classMetaData', SomeClass); // 'a'
Reflect.getMetadata('methodMetaData', new SomeClass(), 'someMethod'); // 'b'Implementation
Inversion of Control
Let's add an IoC factory function to our earlier example:
@Injectable()
class CatService {
constructor(public readonly animalService: AnimalService) {}
run(){
this.animalService.run()
}
}
const factory = <T>(target: Constructor<T>): T => {
const providers = Reflect.getMetadata('design:paramtypes', target); // [AnimalService]
const args = providers.map((provider: Constructor) => new provider()); // Instantiate all
return new target(...args);
};
factory(CatService).run();Using Reflect.getMetadata('design:paramtypes', target) to get constructor parameter types.
Implementing @Controller and @Get
Let's borrow a code snippet from "Deep Dive into TypeScript":
const METHOD_METADATA = 'method';
const PATH_METADATA = 'path';
const Controller = (path: string): ClassDecorator => {
return target => {
// Store the path parameter passed to @Controller
Reflect.defineMetadata(PATH_METADATA, path, target);
}
}
const createMappingDecorator = (method: string) => (path: string): MethodDecorator => {
return (target, key, descriptor) => {
// Store the request method and path separately, using descriptor.value
Reflect.defineMetadata(PATH_METADATA, path, descriptor.value);
Reflect.defineMetadata(METHOD_METADATA, method, descriptor.value);
}
}
const Get = createMappingDecorator('GET');Then implement route mapping:
function mapRoute(instance: Object) {
const prototype = Object.getPrototypeOf(instance);
// Filter out method names from the class
const methodsNames = Object.getOwnPropertyNames(prototype)
.filter(item => !isConstructor(item) && isFunction(prototype[item]));
return methodsNames.map(methodName => {
// fn is the descriptor.value from earlier
const fn = prototype[methodName];
// Get all defined metadata
const route = Reflect.getMetadata(PATH_METADATA, fn);
const method = Reflect.getMetadata(METHOD_METADATA, fn);
return {
route,
method,
fn,
methodName
}
})
};At this point, we can iterate over mapRoute and mount the routes on Express or Koa. The "Deep Dive into TypeScript" book covers this implementation in its Reflect Metadata chapter. However, students without solid TypeScript fundamentals might find it hard to follow. Knowledge is inherently connected — layer upon layer. Sometimes a weak foundation in certain areas causes you to get stuck, and you need to go back and strengthen those foundations before moving forward. Keep pushing! 💪
