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);
}
}
}