Lex Webb
Lex Webb

Reputation: 2852

Typescript decorators - link method decorator to class decorator

I'm working on a library which is going to implement some custom web request routing setup. And i want to be able to implement this functionality using typescirpt decorators like this.

@Controller({ path: '/api' })
class TestController {
  @Route('get', '/')
  get() {
    return 'banana';
  }
}

The issue im having is that i cannot seem to be able to link up the 'child' method decorator to the 'parent' class decorator.

I have some pretty simple decorator factories which you can see here:

export function Controller(params?: IControllerParams) {
  const func: ClassDecorator = (target) => {
    registerController(target, params || {});
    logger.info(`Registered controller: ${target.name}`);
    console.dir(target); // [Function: TestController]
  };

  return func;
}

export function Route(verb: Verb, path: string) {
  const func: MethodDecorator = (target, key) => {
    registerRoute(verb, path, key, target);
    logger.info(`Registered route: ${path} for verb: ${verb}`);
    console.dir(target); // TestController {}
  };

  return func;
}

Now the issue is that the target types returned by each of the decorator instantiations are ever so slightly different, meaning i cannot compare them. The class method returns a Function signature for my class, and the method method returns a named object signature.

Is there somethind i'm missing? I've seen other libraries do this kind of link up so i know it should be possible!

Upvotes: 1

Views: 869

Answers (2)

Rachael Dawn
Rachael Dawn

Reputation: 899

I've actually encountered this exact problem before, and there are significant complications.

The first is that can you lose access to the "this" value very easily, so you have to be careful. The alternative is to treat each function as a static, pure method that just happens to be defined in an object. The second is the order in which the decorators are evaluated, which goes inside out as you likely are already aware.

Bearing both of these in mind, this is what I did. I used this code with Meteor, which did something very similar to what you're doing.

Preamble

A ServerModule was simply a class with a series of method handlers. AKA a controller, and this code was built for Meteor.

The Code

/**
 * This is horribly ugly code that I hate reading myself, 
 * but it is very straightforward. It defines a getter
 * property called __modulle, and returns the data that
 * we care about in a format that is readable for a future
 * registry/bootstrapping system
 */
function boltModuleProperty(proto: any) {
  Object.defineProperty(proto, '__module', {
    get: function () {
      let obj: IModuleDetails = {};
      for (let key in this.__moduleFunctions)
        obj[`${this.__moduleName}.${key}`] = this.__moduleFunctions[key];
      return obj;
    }
  })
}

/**
 * This is evaluated at the very end.
 * 
 * Collect all the methods and publications, registering
 * them with Meteor so they become available via the
 * default Meteor Methods and Subscriptions.
 */
export function ServerModule (moduleName?: string) {
  return function (target: any) {
    boltModuleProperty(target.prototype);
    // Use either a passed-in name, or the class' name
    target.prototype.__moduleName = moduleName || target.name;
  }
}


/**
 * Take the name of the method to be exposed for Meteor,
 * and save it to the object's prototype for later. We
 * we do this so we can access each method for future 
 * registration with Meteor's 'method' function
 */
export function ServerMethod (name: string = null) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    let fnName = name || descriptor.value.name;
    // ensure we actually get the real prototype
    let proto = target.prototype ? target.prototype : target.constructor.prototype 

    if (!proto.__moduleFunctions) proto.__moduleFunctions = {};
    proto.__moduleFunctions[fnName] = descriptor.value;
  }
}

Explanation

You're defining additional information about the class in a format you are able to read and understand. Each method/property you use inside of the class needs to store information about itself, and NOT perform ANY actions. A decorator should never cause any external-facing side-effect ever. I am only making this an important point because you don't want to lose track of how things are happening in your code base.

Now that we have some code to look at, we have to get around that pesky registration and not losing access to some potentially bound code. We have everything we need via the newly created __module property on the class, but it is not visible via typescript yet.

Two options here:

let myInstance: IServerModule & MyClass = new MyClass();
// or
let myInstance: any = new MyClass();

Setting it up

However you're accessing the method registration (express.get, etc), you want have something that takes a reference to the class, stores it in a registry (literally just an array in some boot file, similar to Angular's modules), and registers everything in that boot/module file.

Access the __module property, read the information you stored, and register it as needed. This way you accomplish a separation of concerns, you have a clear understanding of what is being created in your application, and you get to use your decorators exactly as you deem necessary.

Upvotes: 1

Lex Webb
Lex Webb

Reputation: 2852

And of course I work ou the issue shortly after posting. I simply need to compare the prototype of the 'parent' class target to the 'child' method target and they match.

Upvotes: 1

Related Questions