Search code examples
typescriptdecorator

Can I access the target class instance in a Typescript method decorator?


I'm creating a WebSocket server in Typescript in which different application components should be able to register their own request handlers. There's a singleton WebsocketHandler that provides this behavior.

Without decorators, a class can register its request handlers like this:

class ListOfStuff {
  private list = [];

  constructor() {
    //Register listLengthRequest as a request handler
    WebsocketHandler.getInstance().registerRequestHandler('getListLength', () => this.listLengthRequest());
  }

  private listLengthRequest() : WebSocketResponse {
    return new WebSocketResponse(this.list.length);
  }
}

class WebsocketHandler {
  private constructor() {}

  private static instance = new WebsocketHandler();

  static getInstance() {
    return this.instance;
  }

  registerRequestHandler(requestName: string, handler: () => WebSocketResponse) {
    //Store this handler in a map for when a request is received later 
  }
}

class WebSocketResponse {
  constructor(content: any) {}
}

Which works fine. However, I'm trying to replace the registration calls in the constructor with method decorators. Ideally, ListOfStuff would then look like this:

class ListOfStuff {
  private list = [];

  @websocketRequest("getListLength")
  private listLengthRequest() : WebSocketResponse {
    return new WebSocketResponse(this.list.length);
  }
}

But, after creating a decorator factory for @websocketRequest, I can't figure out how to have listLengthRequest() execute in the correct context. I tried this factory function:

function websocketRequest(requestName: string) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    WebsocketHandler.getInstance().registerRequestHandler(requestName, descriptor.value);
  }
}

Which makes this equal to the map the function is being held in (inside WebsocketHandler).

Then I tried passing in target as the handler's context with this factory function:

function websocketRequest(requestName: string) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    WebsocketHandler.getInstance().registerRequestHandler(requestName, () => descriptor.value.call(target));
  }
}

But I then realised that target only refers to the prototype of ListOfStuff and not the actual class instance. So still not helpful.

Is there any way I can get the instance of ListOfStuff (so I can access this.list) in the decorator factory? Should I be structuring my decorator in some other way so that it's tied to the instance of the class rather than its prototype? Here's a Repl demonstrating the issue https://repl.it/@Chap/WonderfulLuckyDatalogs

This is the first time I've messed around with decorators, and I'm pretty new to Typescript too, so any guidance would be greatly appreciated. Thanks!


Solution

  • It is not possible to access an instance in a Typescript method decorator. But it is possible to change the prototype and constructor using decorators. So the possible solution is to use two decorators: the first one that "marks" methods and the second one that changes the constructor adding the registration logic.

    Below I'll try to illustrate the idea

    const SubMethods = Symbol('SubMethods'); // just to be sure there won't be collisions
    
    function WebsocketRequest(requestName: string) {
      return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        target[SubMethods] = target[SubMethods] || new Map();
        // Here we just add some information that class decorator will use
        target[SubMethods].set(propertyKey, requestName);
      };
    }
    
    function WebSocketListener<T extends { new(...args: any[]): {} }>(Base: T) {
      return class extends Base {
        constructor(...args: any[]) {
          super(...args);
          const subMethods = Base.prototype[SubMethods];
          if (subMethods) {
            subMethods.forEach((requestName: string, method: string) => {
              WebsocketHandler.getInstance()
                .registerRequestHandler(
                  requestName,
                  () => (this as any)[method]()
                );
            });
          }
        }
      };
    }
    

    Usage:

    @WebsocketListener
    class ListOfStuff {
      private list = [];
    
      @WebsocketRequest("getListLength")
      private listLengthRequest() : WebSocketResponse {
        return new WebSocketResponse(this.list.length);
      }
    }
    

    Updated repl