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.