tc39/proposal-decorators

@static class decorator to enforce static guarantees?

Closed this issue · 6 comments

Not sure how possible this is, but for example, what if we had a built-in @static decorator that could be used like this:

@static
class Foo {
  foo = 123
}

Then, if someone does something that would destroy the engine's ability to statically know the shape of the class up front, it would through an error at compile time. For example, if the user were to write

@static 
class Foo {
  foo = globalFunctionThatReturnsSomethingUnknown()
}

then there would be an error thrown during parsing (or what the user perceives as parsing, perhaps after actual parsing because this is valid syntax, but also before execution of the module, but maybe also at future points of runtime if something that cannot be analyzed statically may still be valid at runtime).

It would enforce inheritance as well. This would work:

@static class Foo {
  foo = 123
}
@static Bar
  extends Foo { // ok
  bar = 123
}
@static class Baz {
  baz = new Foo // ok
}

but

class Foo {
  foo = 123
}
@static Bar
  extends Foo { // no ok, Foo not static
  bar = 123
}
@static class Baz {
  baz = new Foo // not ok, Foo not static
}

would not work because the base class does not have the guarantee.

What about dynamic stuff at runtime? Maybe it should fail at "parse time" when possible, but it can also throw at runtime, for example:

@static class Foo {
  foo = 123

  someMethod() {
    this.foo = unknownFunction() // unknownFunction happens to return a `number`
  }

  otherMethod() {
    this.foo = otherUnknownFunction() // returns a string
  }
}

const f = new Foo()

f.someMethod() // no error

f.otherMethod() // error, deoptimizes

Or perhaps, the decorator tells the engine to do it's best at static time (possibly throwing an error), but for cases like otherMethod it would still de-optimize like usual (although maybe the engine can log a warning?).

Another name for the decorator could be @struct, implying the shape or structure of the class is known up front.

The constraint of static-ness is that it must be the default, not something users opt into. Performant behavior should be the default behavior.

Besides, there has been more discussion around this and there are options which would enable us to get rid of @init:. The main option is that all initializers would run as a separate stage during class initialization. This way, there would only be one single check during class initialization to see if there are any decorator initializers. However, there has been concern over adding an additional initialization step as it would increase the complexity of class initialization, and it would mean that you can no longer read the class from top-to-bottom and see the initialization order.

Now, with that constraint in mind, I believe that @init: is necessary on a syntactic level to hint to the developer that some initialization step is occuring. Consider:

class Foo {
  foo = this.bar(123);

  @init:someDecorator bar;
}

In this example, with the current spec, the initializer added by someDecorator would run after foo is assigned, meaning that the call to this.bar() in its initializer would be calling the uninitialized version of bar. If @init: were not present, there would be nothing to tell the developer that someDecorator is potentially changing the behavior of bar and thus the value of foo. It's a debugging nightmare and it makes defining classes very difficult.

Personally, I've spent a lot of time discussing the possibilities here and negotiating between the various parties. I am also not very happy that the outcome is more verbose than the original proposal, but at the same time I believe that the extra verbosity does add value. accessor lets users know when a field decorator is intercepting access, which allows them to intuit some of its behavior. @init:, likewise, allows users to know if a decorator is adding some additional behavior on initialization, which will be a massive help when trying to debug why a method works differently when called one way vs another.

For the time being this answer is settled in this proposal. We'll be bringing the current proposal to committee for advancement to stage 3, and based on feedback from that discussion will continue to refine it, but these topics have been discussed and raised many times as is and have definitive answers at the moment.

In this example, with the current spec, the initializer added by someDecorator would run after foo is assigned, meaning that the call to this.bar() in its initializer would be calling the uninitialized version of bar. If @init: were not present, there would be nothing to tell the developer that someDecorator is potentially changing the behavior of bar and thus the value of foo. It's a debugging nightmare and it makes defining classes very difficult.

Could something to this effect be added to the README? No one I've shown the @init syntax likes it. Having some sort of justification for it spelled out in the README would be helpful.

Absolutely, happy to accept PRs for it, otherwise I'll try to find some time when available (lately most of my free time has been going toward the Babel plugin rather than this repo directly).

The constraint of static-ness is that it must be the default, not something users opt into. Performant behavior should be the default behavior.

That is true, and the above idea did not eliminate that. Classes should be optimized by default if they can be, but the idea of @static simply adds a check that causes the compiler to through an error if a decorator user uses a decorator that violates the ability for the class to be optimized. The @static decorator itself does not signal that optimization should be performed, and that aspect stays the same.

Basically the idea is that this new @static decorator forbids a user from doing anything to a class that prevents optimization (throws when that happens), but the new @static decorator itself does not have any impact on optimization itself.