Search code examples
typescript

Updating class instance attributes from the decorator in TypeScript >= 5.0


The goal: I want to associate a method with an action value passed through the method decorator and keep this action-method map on the instance of the class whose method was decorated. In other words, I want to "register" a method into a "table" where each method is associated with an action value, for example, { "send": sendMessage(), ... } and do it through the method decorators.

The problem: When I add a class property for keeping action-method pairs, it's always empty even when I update it in the decorator. Removing the property "solves" the problem, and the code compiles and runs successfully, but then I lose type checking.

How do I fix this? Is there a better approach?

Here is the working code example and a link to TypeScript Playground:

enum Action {
  Send,
  Create,
};

function action(action: Action) {
  return function <This, Args extends any[], Return>(
    target: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
  ) {
    if (context.kind !== 'method')
      throw new Error('This decorator can only be applied to methods.');

    context.addInitializer(function () {
      if (!this.actionsMap)
        this.actionsMap = new Map();

      this.actionsMap.set(action, target.bind(this));
    });
  };
}

class Service {
  // Results in an empty map:
  // actionsMap: Map<Action, Function> = new Map();

  @action(Action.Send)
  public sendMessage() {
    console.log('Sending message...');
  }

  @action(Action.Create)
  public createMessage() {
    console.log('Creating message...');
  }
}

const service = new Service();
service.actionsMap.get(Action.Send)();


Solution

  • According to the documentation for the JavaScript Decorators proposal,

    The addInitializer method is available on the context object that is provided to the decorator for every type of value. [...] The timing of these initializers depends on the type of decorator: [...] Method and Getter/Setter decorators: During class construction, before any class fields are initialized

    So that means if you add an actionsMap class field to Service and initialize it with new Map(), that field will have new Map() written to it after the initializer functions for your sendMessage() and createMessage() methods run. You'd effectively be overwriting the actionsMap you want with something blank.

    Therefore the most obvious way forward is simply not to initialize that class field in your JavaScript. But if you just leave out all mention of it, then TypeScript won't know it should be there. One approach is to use the declare property modifier to tell TypeScript about actionsMap without emitting any JavaScript code for it. Like this:

    class Service {
      declare actionsMap: Map<Action, Function>;
    
      @action(Action.Send)
      public sendMessage() {
        console.log('Sending message...');
      }
    
      @action(Action.Create)
      public createMessage() {
        console.log('Creating message...');
      }
    }
    

    At runtime there will be no mention of actionsMap inside the Service class body, and so you'll get the runtime behavior you expect. But TypeScript will believe that a Service instance has such a property, and you get the type checking you expect as well:

    const service = new Service();
    service.actionsMap.get(Action.Send)?.(); // okay
    

    Playground link to code