proposal-decorators icon indicating copy to clipboard operation
proposal-decorators copied to clipboard

Potential TDZ accessing static methods from instance decorators.

Open theengineear opened this issue 3 months ago • 8 comments

Context

The following (working in Chrome DevTools) code feels natural. I can depend on constructor information when defining prototype information:

class Foo {
  #two = Foo.#one + 1;
  #three = this.#two + 1;

  static #one = 1;
}

Issue

But, the following does not appear to work (using latest Babel transpiler) which feels surprising compared to the above code:

class MyElement {
  @computed(MyElement.#computeFoo) // TDZ!
  accessor foo;
  
  static #computeFoo() {
    return 'foo';
  }
}

I believe the current specification will cause a ReferenceError due to accessing MyElement.#computeFoo in the @computed accessor decorator.

Workaround

I think the current workaround would be to do something like:

class MyElement {
  @computed(() => MyElement.#computeFoo) // works if you can find just the right moment to call this
  accessor foo;
  
  static #computeFoo() {
    return 'foo';
  }
}

Discussion / Motivation

As a developer, it feels like configuration of prototype-related information would be able to rely on constructor-information. I am used to doing this sort of thing when I declare static / non-static fields on classes. The current behavior (again, via the Babel transpiler) feels surprising to me as an experienced developer.

Apologies if I’ve missed something in the specification or if the transpiler is causing me to misinterpret the eventual implementation! Thanks much for taking a look ❤️

theengineear avatar Nov 23 '25 13:11 theengineear

The difference is that #three is an instance field, so its initializer is not evaluated until new Foo(), long after Foo is fully defined.

In this case, you're immediately getting the value of that static private field before Foo is fully defined - so it's quite expected (assuming that @computed invokes its callback immediately).

ljharb avatar Nov 23 '25 22:11 ljharb

@ljharb perhaps this is more surprising:

class MyElement {
  // define this first
  static #computeFoo() {
    return 'foo';
  }

  @computed(MyElement.#computeFoo()) // still error!
  accessor foo;
}

Humorously, this works in Babel:

class MyElement {
  // define this first
  static #computeFoo() {
    return 'foo';
  }

  @computed(MyElement.#computeFoo) // no error in Babel!
  accessor foo;
}

Babel Repl

but errors only in TypeScript:

TypeScript Playground

trusktr avatar Nov 24 '25 07:11 trusktr

The fact that we can read the method (without calling it) in Babel is strong evidence that this can be made to work with all of the existing semantics.

I am of the very strong opinion that source order should be given higher priority. It's one thing authors are fully in control of.

Coming up with arbitrary ordering rules not based on source order is basically intractable, and the current ordering is somewhat arbitrary.

trusktr avatar Nov 24 '25 07:11 trusktr

It's not an issue of "can be made to work" - it's that you're not supposed to be able to reference a partially constructed class, and there may be a Babel bug here.

In other words, if your decorator needs to invoke the callback immediately, you can't use class methods in it.

ljharb avatar Nov 24 '25 19:11 ljharb

The awesome thing about source order would be the author gets to decide, rather than a seemingly-arbitrary spec rule.

If source order were first class, then if something would need to be ready before something else, the solution would be dead simple: move it further up.

It would be just like let/const: variables that come after can use variables that come before. Super simple!

I'm not seeing a reason why anyone would not want execution in source order.

Can you please explain with some good examples?

trusktr avatar Nov 30 '25 08:11 trusktr

Thanks @ljharb and @trusktr for the discussion!

I want to give a bit more context on why this restriction feels surprising from a developer’s point of view, and why allowing prototype / instance decorators to reference static members later (rather than at decorator-expression evaluation time) would feel more natural / expected.


The existing language model already allows “constructor-to-prototype wiring”

Developers already rely on the fact that prototype / instance initializers are allowed to reference private static helpers on the same class:

class MyElement extends HTMLElement {
  #x = MyElement.#computeX(this);

  static #computeX(instance) { /* … */ }
}

This is perfectly valid, because the execution of the initializer is delayed until after the class exists. The language ensures that the “class configuration phase” proceeds in an intuitive way.

From a user perspective, decorators look like they participate in that same “class configuration phase.”


But decorators violate that expectation

When writing a decorator, developers naturally reach for this pattern:

class MyElement extends HTMLElement {
  @compute(MyElement.#computeX) // <-- intuitive
  accessor #x;

  static #computeX(instance) { /* … */ }
}

And the TDZ rules make that invalid because the decorator expression is evaluated too early.

What’s important is that this failure is not because the user is asking for something fundamentally unsafe or conflicting with class semantics… they are asking for something that the language already allows in every non-decorator context.

Instead, the failure is an artifact of when decorator expressions are evaluated, not what they’re trying to do.


The forced workaround is mechanically possible, but ergonomically backwards

Because of the early decorator-expression evaluation, users must wrap references in a callback and hope the decorator only uses it at a safe time:

class MyElement {
  @compute(() => MyElement.#computeX) // extra indirection
  accessor #x;

  static #computeX(instance) { /* … */ }
}

This is possible, but it is conceptually backwards:

  • Decorators are supposed to make class configuration easier and more declarative.
  • Instead, the developer has to bend to a subtle evaluation-ordering constraint.

The language-first TDZ perspective is internally consistent, but the developer-first mental model (“prototype members can rely on constructor helpers”) feels broken.


Allowing delayed access expands expressiveness

Crucially, changing the timing of decorator evaluation does not invalidate any existing behavior.

Everything that works today continues to work:

  • Decorator expressions would still be evaluated deterministically.
  • Decorator effects that depend on the completed class can run later.

This is strictly an addition of capability. I.e., it seems unlikely that there is code today that relies on the fact that SomeClass is not available inside decorator arguments. It is simply an artifact of the current phasing.


The developer intuition is consistent, but the current semantics are not

To a typical developer:

  • Static things belong to the constructor.
  • Instance / prototype things belong to the prototype.
  • During class setup, these sides can see each other.

This model is supported everywhere except in decorators, which makes decorators feel like a “special case” rather than a natural extension of classes.

That is a signal that the semantics may be overly constrained by the current evaluation schedule, not by a core language design principle.


Again, thanks for reading through all this (I know it’s a lot of text!) — I just really feel like there’s an asymmetry creating a PoLA violation here. I do really appreciate the consideration :heart:

theengineear avatar Nov 30 '25 16:11 theengineear

The awesome thing about source order would be the author gets to decide, rather than a seemingly-arbitrary spec rule.

I don’t mean to side-step this comment. I do agree with this @trusktr! But, I’m not sure that this quite fixes the “asymmetry” I mention above. I.e., yes to this, but I think there is something that would still feel off about the mental model which differentiates prototype initialization from constructor initialization.

theengineear avatar Nov 30 '25 16:11 theengineear

But decorators violate that expectation

Fully agreed. This is very awkward.

they are asking for something that the language already allows in every non-decorator context.

Very good point indeed.

This model is supported everywhere except in decorators, which makes decorators feel like a “special case” rather than a natural extension of classes.

Yeah

but I think there is something that would still feel off about the mental model which differentiates prototype initialization from constructor initialization.

Yeah, if at least it would run in the order of static decorators first then instance/prototype decorators (source order for both) I believe it would be far more intuitive.

Currently it's not even source order in the instance/prototype case, which adds even more confusion:

the current ordering is somewhat arbitrary.

trusktr avatar Dec 03 '25 23:12 trusktr