Skip to content

The Origin of the Problem ​

Both NestJS and MidwayJS implement Dependency Injection (DI) and Inversion of Control (IoC) 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's tutorials:

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 attach 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();

Here we use Reflect.getMetadata('design:paramtypes', target) to get the 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; readers new to TypeScript may want to review the fundamentals first.