-
Notifications
You must be signed in to change notification settings - Fork 106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Why are class decorators applied before static fields? #329
Comments
Agree. Calling static decorators after the class one may also ruin the system of metadata if a user or a decorator system developer (as me with my framework) considers using static members as a part of the decorator system. IMO, class decorator is the final call; it should finalize all the work with decorators. |
I tend to agree that static fields would be useful from within decorators.
I don't think this language is helpful though. I'm sure there's either a reason or an oversight, and Dan has been nothing but extremely accommodating to the many competing and often conflicting in this conversation. |
It doesn't look like an oversight, so, probably, there is some reason we miss. And Dan is great 😁 |
Assuming there's justification for the current ordering, would |
This decision was really made long ago, in the May 2016 TC39 meeting in Munich, where the committee approved @bterlson 's and @wycats 's proposal for the ordering of execution of all class elements, including field evaluation and decorators. In this proposal, static fields can reference the class (for example, to instantiate it). This means that the class is fully executed before static fields run, in order for the constructor to no longer be in TDZ, and then the static fields are added on top. Part of executing the class is running the class decorators. Since the class decorator may replace the class, but the inner binding of the class within the class body is const, it would not be possible to delay executing class decorators until after static fields, as this would require us to mutate an immutable binding. I believe TypeScript takes this path out--of switching to a mutable binding for decorated classes (@rbuckton can confirm)--but I doubt TC39 would go for such an inconsistency unless we had a very strong reason for it. Static public fields with access to the constructor in the scope of the initializer (and immutable inner class bindings) are already shipping in several JS implentations--including multiple web browsers--so I don't really see this aspect as open for change. |
Note that the order is only contingent on the inner declaration being the decorated class, which itself would break private static methods e.g. this would fail if function dec() {
return klass => class extends klass {}
}
@dec
class Foo {
static #bar() { return 12 }
static bar() { return Foo.#bar() }
}
Foo.bar(); It'd be safest if the inner binding were simply the undecorated class. |
You're right that making the inner binding be the undecorated class could solve this problem in a different way while maintaining consistency with static field initializer and immutable inner class binding semantics, but I think it would create many others. For example, say the decorator returns a subclass of the declared class which adds some behavior. Now, this behavior will not be accessible from the inner binding, contradicting expectations. I believe that most JS developers don't even know that there are these multiple bindings, so it could be especially confusing to debug. EDIT: See @wycats 's much more readable example of this issue below |
Both ways around have behaviour contradicting expections. Like the example I gave above will not work as Alternatively if all static private methods/fields are installed on the new decorated class then this means all private methods/fields must be in TDZ during decoration so a simple refactoring that adds a private field will break if the decorator tries to read it. For example suppose we have a class like this: // Adds attributes onto the class
function addReflectedAttributes() {
return klass => {
for (const attribute of klass.reflectedAttributes ?? []) {
Object.defineProperty(klass.prototype, attribute.name, {
set(value) { this.setAttribute(attribute, attribute.serialize(value)); }
get() { return attribute.deserialize(this.getAttribute(attribute.name)) },
});
}
return klass;
}
}
@addReflectedAttributes
class MyElement extends HTMLElement {
static get reflectedAttributes() {
return [
{ name: 'my-attribute', serialize: String, deserialize: Number },
];
}
} Then BUT if we change it slightly to use a private field: @addReflectedAttributes
class MyElement extends HTMLElement {
static #deserialize(value) {
if (value.match(/^[0-9]+$/)) return Number(value);
else if (value === 'never') return -Infinity;
else return undefined;
}
static get reflectedAttributes() {
return [
{ name: 'my-attribute', serialize: String, deserialize: MyElement.#deserialize },
];
}
} Then it breaks, because Personally I think an evaluation model where the ability to refactor to using private fields is broken by the presence of a decorator means the decorator model is very unexpected and effectively broken. Again the solution is just to completely evaluate the class and pass that to the decorator. |
Regardless of the evaluation model, I think it would be important to expose both the original class and decorated class to the body somehow. Probably as metaproperties: function decorate() {
return klass => class extends klass { static boz() { return 12; } }
}
@decorate
class Foo {
static #baz() {
return 12;
}
static bar() {
// class.original is the undecorated class
return class.original.#baz();
}
static qux() {
// class.decorated is the decorated class
return class.decorated.boz();
}
} |
This seems like that class wasn't actually written to be paired with the decorator, then. If the intention was to actually add additional behavior via a subclass, then I shouldn't be directly referring to my original class binding at all. Base classes that are written to allow subclassing behavior usually use class Base {
static subclassOverridable() {
console.log('Base');
}
test() {
// This is broken. I directly referred to Base, not the current subclass.
Base.subclassOverridable();
// Instead, indirect though `this`
this.constructor.subclassOverridable();
// Or Class Access Expression
class.subclassOverridable();
}
}
class Sub extends Base {
static subclassOverridable() {
console.log('Sub');
}
} I don't get why a class decorator would have super powers to change the lexical binding. The And I really don't understand why we'd allow a static field's |
It seems follow-on function decorators also have similar issue: @deco function f() {
f // <-- original f or decorated f?
} |
I have stated reasons for disagreeing with the value judgements expressed above by @jridgewell and @Jamesernator , but I believe this subject deserves further discussion. I think this is a question that we can iterate on within Stage 2. Maybe we can discuss it at some of the biweekly calls. We have articulated two alternatives here (let the inner binding and outer binding disagree and run class decorators after static field initializers, or the current proposal where class decorators run before static fields are added). I agree with @hax that the choice here has implications for function decorators about what the inner binding points to and whether it is in TDZ while the function decorator is executing. There is a third alternative, of letting class decorators only perform side effects, and not replace the class, but I suspect that this would be too limiting. Or even a fourth--remove the inner class binding if the class is decorated--but I also see this as too limiting (but maybe consistent with @jridgewell 's analysis). |
And a fifth alternative is to punt on class decorators and leave them for a follow-on proposal, focusing on just field and method decorators initially. |
If decorators are just functions, let's treat them like functions ... they run when the thing they decorate is evaluated? So
is (effectively, for simply making the point)
I'd expect functions/all to follow similarly. They apply just as functions would when called. |
@jsg2021 Well, if |
When I originally proposed the current ordering in 2016, I was fairly motivated by examples like this one. @someDec
class DateTime {
static EPOCH = new DateTime();
}
DateTime.EPOCH instanceof DateTime; // the current status quo makes this true
DateTime.EPOCH.constructor === DateTime; // the current status quo makes this true If we made the inner binding and the outer binding different, it would break these expected equivalences not only in theory but also pretty often in practice. |
To clear up one piece of confusion: in @Jamesernator 's example: function dec() {
return klass => class extends klass {}
}
@dec
class Foo {
static #bar() { return 12 }
static bar() { return Foo.#bar() }
}
Foo.bar(); Here, it's important to note that |
What happens with the inner binding if methods are called before returning from the decorator e.g.: function dec() {
return klass => {
klass.bar();
return klass;
}
}
@dec()
class Foo {
static bar() {
return Foo;
}
} e.g. What does I still have signficant concerns about evaluation order, for example the At current this works: class MyElement extends HTMLElement {
static observedAttributes = ['my-attribute'];
attributeChanngedCallback() {
// will be called whenever my-attribute changes
}
}
customElements.define('my-element', MyElement); However if we use the @defineElement('my-element')
class MyElement extends HTMLElement {
// This attribute is only available *after* the class decorator has run
static observedAttributes = ['my-attribute'];
attributeChangedCallback() {
// OOPS this is never called now
}
} This seems like a surprising difference given I honestly don't think that either evaluation model will meet most developers expectations, I imagine a lot would expect I think at the very least, it'll be necessary to have a form similar to function defineElement(name, options) {
return klass => {
customElements.define(klass, name, options);
}
}
@init: defineElement('my-element')
class MyElement extends HTMLElement {
static observedAttributes = ['my-attribute'];
attributeChangedCallback() { /* works */ }
} |
Ultimately, decorator authors are engaging in metaprogramming and will see some interesting edge cases of the language if they use the feature in certain ways. I think it's just inherent that, if class decorators affect the inner class binding, then the decorator will be executed before the whole class definition has completed. TC39 will never meet all of the developer intuitions about what features could be, since they are just contradictory, but we make tradeoffs anyway. We could add these |
The definition of the element: it's lifecycle callbacks and static properties like class MyElement extends HTMLElement {
attributeChangedCallback(name, oldVal, newVal) {
console.log('attributeChangedCallback', name);
}
}
customElements.define('my-element', MyElement);
MyElement.observedAttributes = ['foo']; |
If we change static field to static getter, could it work? @defineElement('my-class')
class MyElement extends HTMLElement {
static get observedAttributes() { return ['my-attribute'] }
attributeChangedCallback() { ... }
} |
No. The value is read off at definition time. |
Could we put the I'd be curious to understand why the static getter doesn't work, but it does seem like it's important to find a good solution for static fields. |
@justinfagnani I'm trying to understand why It seems to me that it's effectively used as a parameter to the decorator, so why not: @defineElement('my-element', { observedAttributes: ['my-attribute'] })
class MyElement extends HTMLElement {
attributeChangedCallback() {
// called now!
}
} |
@wycats seems work, though we lost some declarative (for example can't decorate |
@wycats the current |
@hax can you explain what you'd hope to achieve by decorating
@justinfagnani can you show me an example of this use-case with the current decorators? |
@wycats the LitElement It works with any custom element class. |
I'm not sure if we should make this change, but how would people feel if class decorators:
That's one way to meet these goals:
What sorts of use cases would these semantics fail to meet? How do we want to prioritize these use cases? |
I'm in favour of restricting class decorators to metadata and side effects. One of my main concerns with this proposal has been that it would introduce many decorators like this: const wrap = (klass) => class extends klass {} Which when used in combination would produce long prototype chains, that users of the decorators may not be aware of, where as with other patterns like mixins it's much more obvious how much the prototype chain is growing. Another issue is knowing the shape is consistent. It seems beneficial to avoid confusing cases where using a wrapping decorator might cause an error: const wrapWithFunc = (klass) => () => new klass();
@wrapWithFunc
@wrap // Uncaught TypeError: klass is not a constructor
class A {} Wrapping can still be achieved by wrapping classes in function calls. const A = wrapWithFunc(class {}) The issue being that the name property of If const decorators get a follow-up proposal those could even be used in situations where wrapping is desired. If the decorators where to also supply the variable name to the context object, then inferring the wrapped values name would also be possible. const wrapWithFunc = (klass, { name }) => {
return { [name]: () => new klass() }[name]; // alternatively you could use Object.defineProperty to change the name.
}
const @wrapWithFunc A = class {} // const decorator |
I would assume that wrapping a class must produce something with [[Construct]], so your “returns an instance” decorator would be invalid. |
@ljharb that seems like a good restriction. Just took a look in the proposal again and saw that return value for class decorators as it's written now expects a "new" class, which I assume is a mistake since that eliminates the possibility of metadata / side effect only decorators. That does mean the current proposal isn't compatible with decorators that @jsg2021 linked above. |
We're at a bit of an impasse here, since there's no way to meet all of the goals stated in this thread at once. There is no way that class decorators could replace the class, and make that available to the static field initializers, while also making the static fields already initialized and visible to the decorator before it runs. Here's an example that illustrates the contradiction: let props, a, b;
function dec(k) {
props = Object.keys(k);
a = k.m();
b = k.f;
return class D extends k { static x = 1; };
}
@dec class C {
static m = () => C;
static f = C;
}
assert([...props].toString() === "m,f"); // Both of the static fields are added before the decorator runs
assert(C.m().x === 1) // The binding inside the arrow function is to D, the result of dec running
assert(C.f.x === 1) // The binding of f is also to the result of dec
// Now, we bend space-time! These didn't get reset from when they were originally created, right?
assert(a === C.m());
assert(b === C.f);
// But how can that work, given that b *is the result of* dec?? This example shows that we have to decide between three imperfect options:
To allow replacing the constructor behavior (but not class identity), how about decorators for the constructor? It could look like this: @classDecorator class C {
@constructorDecorator constructor() {
}
}
Because Constructor decorators could be used for:
These patterns do not include replacing the class with a Proxy, as mentioned above, since that feature runs into the impossibility of meeting the various constraints that have been asserted about how class decorators that replace the class interact with static field initializers. For replacing the constructor identity, I'd suggest continuing with the pattern of placing an assignment after the class declaration. This will make it more clear that any calculation to reset the class will affect usages outside the class, but not reset static field declarations inside the class. People often cite React-Redux |
@littledan I really like the idea of trap style const freezableCtors = new WeakSet();
const frozen = ({ constructor, target }) => {
freezableCtors.add(target);
return {
constructor () {
if (!freezableCtors.has(new.target)) {
throw new Error(`Subclass ${new.target.name} must be also be @frozen`);
}
constructor.apply(this, arguments);
if (new.target === target) {
Object.freeze(this);
}
}
};
} |
I thought the discouragement was more because of the uncertainty of decorators. I admit, with the direction away from classes, decorators in React do not feel like a highly sought feature. I still think that decorators should still have the same timing/order as a regular function. ie: in |
I don't disagree, but I'm arguing that the experience shows, it's viable to ask developers to change the class binding in a line after the class declaration.
This would mean that the inner bindings inside the class point to the class before it is decorated. In #329 (comment) , @wycats gave an example of the unintuitive results this would cause. I think these results are more unintuitive when decorator syntax is used than when it's an assignment after the class declaration. |
@robbiespeed I guess this decorator would work if you made sure to decorate each subclass. Two errors:
|
@littledan that makes sense, I wasn't familiar with const freezableCtors = new WeakSet();
const frozen = ({ constructor, target }) => {
freezableCtors.add(target);
return function FrozenTrap () {
if (!freezableCtors.has(new.target)) {
throw new Error(`Subclass ${new.target.name} must be also be @frozen`);
}
const instance = Reflect.construct(constructor, arguments, new.target);
if (new.target === target) {
Object.freeze(instance);
}
return instance;
}
};
const addY = ({ constructor }) => class AddZTrap extends constructor {
constructor () {
super(arguments);
this.y = 2
}
};
class A {
@addY
@frozen
constructor () {
this.x = 1;
}
static foo () {
return 'foo';
}
} Which would roughly desugar the class too: class __A {
constructor () {
this.x = 1;
}
}
function A () {
if (!new.target) {
throw new TypeError(`Class constructor ${A.name} cannot be invoked without 'new'`)
}
return Reflect.construct(__A_frozen, arguments, new.target);
}
Object.defineProperty(A, 'prototype', Object.getOwnPropertyDescriptor(__A, 'prototype'));
A.prototype.constructor = A;
Object.assign(A, {
foo () {
return 'foo';
}
});
const __A_addY = addY({ constructor: __A, target: A }, { kind: 'constructor' });
const __A_frozen = frozen({ constructor: __A_addY, target: A }, { kind: 'constructor' }); You mentioned having a optional call trap as well to allow for call constructor behaviour, how would you supply that if the return of the decorator is a function or class? |
Was it forbidden to place decorators on |
@bergus it's come up before and I really like the idea of using constructor decorators to sidestep a number of issues that have come up about class decorators. |
I really like the idea of I wonder if for symmetry purposes it would be a good idea to instead of allowing decorating the class (and requiring the decorator to return undefined), that instead we allow decorating the static block (assuming that proposal succeeds). Decorating the class binding could still come later if there's determined to be a strong need even with static block decorators. |
@littledan Are constructor decorators still gonna be added to the proposal? The new addition of class init decorators alleviates some of the cases (e.g. |
The current thinking was that allowing the |
This is addressed by the ability to |
In the proposal it is mentioned:
However there doesn't seem to be any justification for this bizarre decision despite it locking out specific decorators (such as
@frozen
as is mentioned in the README).With the other decorators, the value they passed is always complete, this seems an arbitrary asymmetry with class decorators to get a partially complete object.
This is even contrary to another part of the README which suggests
@defineElement
could be desugared to:However if
MyClass
hasstatic
fields, then any desugaring would have to become:Which means any transpiler implementation of class decorators also needs to process all static fields.
I would propose that it would be simpler for class decorators to simply run last, once all static methods and fields are processed.
The text was updated successfully, but these errors were encountered: