Skip to content

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:

typescript
@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:

typescript
@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:

javascript
@testable
class MyTestableClass {
  // ...
}

function testable(target) {
  target.isTestable = true;
}

MyTestableClass.isTestable // true

As we can see, the decorator function receives the class itself through its target parameter.

Method Decorators

Let's review method decorators with another example:

javascript
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:

bash
npm i reflect-metadata --save

Then configure the emitDecoratorMetadata option in tsconfig.json:

json
{
  "compilerOptions": {
        "emitDecoratorMetadata": true
  }
}

Let's look at the basic usage of reflect-metadata:

typescript
@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:

typescript
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:

typescript
@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":

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:

javascript
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! 💪