Constructor decorators

Decorators is stage 3 so probably won't get any changes, as such I propose this idea here.

So currently using class decorators to override constructor has the footgun that you break encapsulation of the class, for example consider the following decorator that freezes instances:

function traced(klass, ctx) {
     return class extends klass {
          constructor(...args) {
               console.log("Called constructor", { ctx, args });
               super(...args);
          }
     }
}

@traced
class Point {
     x;
     y;
     constructor(x, y) {
         this.x = x;
         this.y = y;
     }
}

Using this pattern still leaks the original class, as it can be trivially obtained just by getting the prototype of the decorated class:

const OriginalPoint = Object.getPrototypeOf(Point);

// Works, this breaks decorators that care about encapsulation
// e.g. if this was recorded for security reasons, then an XSS attacker could
// use this technique to avoid detection
const point = new OriginalPoint(0, 0);

This isn't actually repairable as far as I can tell either, because classes use the current prototype to determine super, i.e. if we do:

function traced(klass, ctx) {
     const newClass = class extends klass {
          constructor(...args) {
               console.log("Called constructor", { ctx, args });
               super(...args);
          }
     };
     Object.setPrototypeOf(newClass, Object.getPrototypeOf(klass));
     Object.setPrototypeOf(newClass.prototype, Object.getPrototypeOf(klass.prototype));
     return newClass;
}

If you do this and try to construct Point now you get the error:

Super constructor null of newClass is not a constructor 

In order to fully repair the class, I believe the only solution is to use Reflect.construct and return override:

function traced(klass, ctx) {
     const newClass = class extends klass {
          constructor(...args) {
               console.log("Called constructor", { ctx, args });
               return Reflect.construct(klass, args, newClass);
          }
     };
     Object.setPrototypeOf(newClass, Object.getPrototypeOf(klass));
     Object.setPrototypeOf(newClass.prototype, Object.getPrototypeOf(klass.prototype));
     return newClass;
}

This is quite a bit of boilerplate and nuanced just to maintain encapsulation, especially compared to something like a method decorator which can just return a new function.

The proposal

I'd like to propose the ability to decorate the constructor function itself directly as if it were a method. i.e. We could write the following and it would work regardless of whether used on a method or constructor:

function traced(methodLike) {
     return function(...args) {
          console.log(`Called ${ methodLike.name }`, args);
          return methodLike.call(this, ...args);
     }
}

class Point {
     x;
     y;

     @traced
     constructor(x, y) {
         // ...
     }

     @traced
     method() {
         
     }
}

As for semantics, what I propose is that a constructor decorator is given a function which is basically:

function constructorMethod(...args) {
     return Reflect.construct(
         // This is the original class constructor
         originalClassConstructor,
         args,
         // The final decoratedClass (including class decorators) 
         fullyDecoratedClass,
     );
}

The class constructor would simply be replaced by a function which calls the decorated class constructor, i.e. as a function Point would be:

function Point(...args) {
    return decoratedConstructorFunction(...args);
}

Class decorators would then be applied to this function, with .prototype, [[Prototype]] and all the usual class features put onto this decorated constructor function.

FAQ

Why do constructor decorators receive a function rather than a constructor?

This is because, even if we were to switch on the decorator type and do new originalConstructor the new.target would be wrong as we don't have access to the fully decorated class at the stage these decorators are applied.

i.e.:

function traced(methodLike, { kind, name }) {
     if (kind === "method") {
         return function(...args) {
            console.log(`Called method ${ name }`, args);
            return methodLike.call(this, ...args);
         }
     } else if (kind === "constructor") {
          return function(...args) {
              console.log("Called constructor", args);
              // OOPS, wrong new.target again
              return new methodLike(...args);
          }
     }
}

Hi @Jamesernator - another good place to get your suggestion noticed is at: Issues · tc39/proposal-decorators · GitHub

Cross-referencing @Jamesernator 's similar issue there Allow decorating the "constructor" as a function · Issue #419 · tc39/proposal-decorators · GitHub

This pattern looks like it wouldn't work for constructors of child classes, their constructors don't have access this before the call to super() has completed. So the function returned by the decorator also couldn't be provided with access to this.

I think these things are what makes the construction harder to treat the same as a method, their have subtly different semantics around this and the interpretation of the return value. Maybe not insurmountable though.

Yes they wouldn't be given this, although actually thinking about it this would be need to be new.target so subclassing works, i.e. the wrapper function passed to the decorator would look like:

function decoratedConstructor(...args) {
    // Using this, rather than decoratedClass as decoratedClass itself might
    // be subclassed
    return Reflect.construct(originalConstructor, args, this);
}

Yes, they are different from methods, however the (to be proposed neet meeting) function decorators would be different from methods too.

The main goal here is to be able to write decorators like traced, cached, checkParams, checkReturn, etc, etc that can be applied to any function-like in an easy way.

1 Like