This document attempts to enumerate the differences between the current Stage 3 proposal for public and private instance fields and a proposal to reject the current status in favor of a new surface syntax and several additional restrictions.

It calls the current proposal "The Stage 3 Proposal" and the proposal to start over "The Clean Slate Proposal".

For reference, the Clean Slate Proposal refers to itself as "Classes 1.1", "Max-Min Classes" and "Max-Min Classes 1.1" at various points. "The Stage 3" proposal is made up of several smaller proposals.

This document mostly avoids comparing the clean slate proposal with Stage 1 or Stage 2 proposals, except as a future-proofing exercise, to analyze how those proposals would fit in with the changes in syntax or semantics.

It was written relatively recently, as the clean slate proposal was only recently made public. Apologies for any mistakes, typos or inadvertent offense.

Public Instance Fields#

"Public fields" allow users of classes to declare properties that will be installed on instances of the class when the class is intantiated.

ES2015#

In ES2015, a class can install properties onto its instances in its constructor.

The following example is used throughout this document to illustrate various aspects of the two proposals.

class Flattener {
  constructor() {
    this.ids = [];
    this.output = [];
  }

  concat(array) {
    this.ids = [...this.ids, ...array.map(i => i.id)];
    this.output = [...this.output, ...array];
  }

  finish() {
    return { ids: this.ids, output: this.output }
  }
}

Stage 3 Proposal#

The Stage 3 proposal provides a way to declare the two properties in the class bodies and initialize them:

class Flattener {
  ids = [];
  output = [];

  concat(array) {
    this.ids = [...this.ids, ...array.map(i => i.id)];
    this.output = [...this.output, ...array];
  }

  finish() {
    return { ids: this.ids, output: this.output }
  }
}

In the Stage 3 proposal:

  • Public instance fields are initialized as properties just as if they were defined in the constructor. In the absence of decorators, they are not otherwise reflected, and can be understood as sugar for defining the property in the constructor.
  • Public instance fields have initializers, which have the same lexical scope as ES2015 instance methods, with a few early restrictions, and are executed once per instance before this is available to the constructor.
  • Public instance fields are defined as properties (not assigned), which means they do not invoke setters in the superclass. The committee strongly felt that support for conveniently defining properties was important.
  • Public instance fields are initialized as properties in subclasses immediately after the constructor's super().

Stage 3 Public Fields Plus Stage 2 Decorators#

Public field decorators (currently at Stage 2) provide a way to configure the behavior of public fields.

For example, while public instance fields define writeable properties by default, a @readonly decorator can change that behavior:

class Flattener {
  @readonly ids = [];
  @readonly output = [];

  concat(array) {
    this.ids = [...this.ids, ...array.map(i => i.id)];
    this.output = [...this.output, ...array];
  }

  finish() {
    return { ids: this.ids, output: this.output }
  }
}

let f = new Flattener();
Object.keys(f); // []

Decorators can also be used to mark properties non-configurable or non-enumerable, and can turn fields into getter/setter pairs for various uses that have been described in prior presentations to the committee.

Clean Slate Proposal#

The clean slate proposal offers no additional syntax for declaring fields, leaning on the semantics of ES2015 constructors.

This has several consequences:

  1. The dominant way of installing properties on a class instance will invoke superclass setters.
  2. Since there is no declarative syntax for these properties, there is no place for Stage 2 decorators to configure the attributes of those properties.
  3. Since there is no declarative syntax for these properties, Stage 2 class decorators are unable to see them in the list of class elements, and cannot configure them. For example, this means that it would be impossible to write a class decorator that made all declared properties non-configurable (because there is no syntax for declaring properties).

The clean slate proposal suggests that decorators are orthogonal to the changes it proposes, but the elimination of declarative fields and field initializers eliminates key use-cases identified by the champions of decorators and the wider community.

Annotating Instance Variables in More Depth#

The clean slate proposal suggests that it would be possible to add decorators on top of its syntax by creating a "dummy" syntax to hang decorators onto:

class Flattener {
  // early error
  ids;

  // special-case syntax for decorators
  @readonly ids;

  constructor() {
    this.ids = [];
  }
}

The authors of the clean slate proposal have indicated that they are skeptical of decorators in general, but have presented this proposal as a possible future extension.

This possible extension has a number of problems:

First, it is purely a metaprogramming construct: the identifier has no intrinsic meaning. This means that users cannot easily understand @readonly ids as being a modification to the attributes of the ids property. Instead, it would need to be understood in terms of a generalized metaprogramming facility.

As an aside, this view of the identifier confusingly conflicts with other de facto uses of the syntax in source-to-source translation tools such as TypeScript that were identified by the authors of the clean slate proposal as an alternative to decorators.

Second, if we assume a future where the clean slate proposal gains support for method decorators, the semantics of method decorators would be quite different from the semantics of field decorators. Specifically, in the current draft, decorators take in a class element descriptor, and can tweak the behavior of the class element. It's not entirely clear what an identifier descriptor would do, and the authors of the clean slate proposal have not yet explained it.

Third, the speculative future extension does not restore initializers. This makes it non-obvious how to tie together the decorated identifier to the imperative initialization of the same-named-field in the constructor. This is needed in the @readonly first example to turn the identifier into a read-only data property.

It might be possible to install a getter/setter pair onto the prototype (it's hard to tell without a more concrete proposal), but it's not clear how to create a non-writable data property.

Finally, the semantics of field declarations in the Stage 3 proposal are important to users of TypeScript (and other JS type systems).

class Flattener {
  ids: number[] = [];
  output: Array<{ id: number, item: string }> = [];

  concat(array: Array<{ id: number, item: string }>) {
    this.ids = [...this.ids, ...array.map(i => i.id)];
    this.output = [...this.output, ...array];
  }

  finish() {
    return { ids: this.ids, output: this.output }
  }
}

This example adds type annotations to the two class fields. This is so important to the way TypeScript understands instances of classes that it is one of the few annotations that TypeScript requires even in its loosest mode.

Losing declarative fields doesn't simply mean that we can do the same thing in the constructor body. It also means that we are abandoning a growing set of use-cases for annotating those declarations, both in earlier-stage standards track feature (decorators) and external tools (type systems).

The authors of the clean slate proposal have not left fields open as a possible future extension. The proposal argues strongly against fields, because the authors strongly feel that public fields will "lead irresistibly toward private fields and private methods".

Private Instance State#

"Private state" allows users of a class to declare private, lexical, per-instance storage.

In this context, "private" means:

  • not accessible to reflection APIs or dynamic access via []
  • accesses are not trapped by proxies on the receiver

This distinguishes private state (sometimes referred to as "hard private") from symbols and name mangling (sometimes referred to as "soft private").

ES2015#

ES2015 added WeakMaps to JavaScript, making it possible to reliably and straight-forwardly create private, lexical, per-instance storage:

const PRIVATE_PERSON = new WeakMap();

class Person {
  constructor(first, last) {
    PRIVATE_PERSON.set(this, { first, last })
  }

  full() {
    let { first, last } = PRIVATE_PERSON.get(this);
    return `${first} ${last}`;
  }
}

This works reliably, but is a bit verbose, insufficiently say-what-you-mean, and has undesirable garbage collection semantics.

Solution: Syntax#

Both the Stage 3 proposal and the clean slate proposal create ergonomic syntax for defining private storage in a class and accessing it from other members of the class body.

Stage 3 Proposal: Private Instance Fields#

class Person {
  // declare two private fields
  #first;
  #last;

  constructor(first, last) {
    this.#first = first;
    this.#last = last;
  }

  full() {
    return `${this.#first} ${this.#last}`;
  }
}

Like public instance fields, "private instance fields" are declared inside of class bodies using #name. They can be accessed (set or get) using .# notation on any instance of the enclosing class, but only inside of the class body in which they were declared.

They are unavailable through external reflection, proxies do not trap accesses to them on the receiver, and they cannot be accessed dynamically using [] syntax. These are all consequences of privacy.

Like public instance fields, private instance fields can have initializers, and the scope of initializers is the same as instance methods, with a few early restrictions.

The authors of the clean slate proposal have correctly observed that some members of the wider JavaScript community have reacted to the # sigil with visceral dislike.

Clean Slate Proposal: Instance Variables#

class Person {
  // declare two private fields; var is meant to suggest Instance VARiable
  var first;
  var last;

  constructor(first, last) {
    this->first = first;
    this->last = last;
  }

  full() {
    return `${this->first} ${this->last}`;
  }
}

In this proposal, "instance variables" are declared inside of class bodies using a var declaration. They can be accessed (set or get) using -> notation on any instance of the enclosing class, but only inside of the class body in which they were declared.

They are unavailable through external reflection, proxies do not trap accesses to them on the receiver, and they cannot be accessed dynamically using [] syntax. These are all consequences of privacy.

Instance variables cannot have initializers.

Decorators#

In the Stage 2 decorator proposal, decorators on private fields behave similarly to decorators on public fields, with a major difference: they also receive get and set functions that they can use to access the underlying private storage.

For example, it is possible to write a decorator for a private field named @readable that generates a public getter (with no setter) to access the private storage:

class Person {
  @readable #first;
  @readable #last;

  constructor(first, last) {
    this.#first = first;
    this.#last = last;
  }

  full() {
    return `${this.#first} ${this.#last}`;
  }
}

let p = new Person('Allen', 'Wirfs-Brock');
p.first // 'Allen'
p.last // 'Wirfs-Brock'
p.first = 'Alan' // Exception

This might be possible with decorated instance variables, but it's not clear because the instance variable proposal does not describe a speculative semantics for decorated instance variables.

Another use-case identified by the community for decorated private state is the ability to turn a private field into a private accessor.

Here is one example of that use-case using the syntax of the Stage 3 proposal:

class Component {
  // automatically triggers the render method when set
  @observed #person;

  update(person) {
    this.#person = person;
  }

  render() {
    // update the DOM
  }
}

This works because it is possible to turn a private field into a private accessor. To the frameworks that desire this capability, @observed #person is superior to using a public property, because it is still impossible for external code other than the @observed decorator to mutate the #person field.

In contrast, the clean slate proposal does not offer a way to turn instance variables into accessors. As a result, even if decorated instance variables were added, they would not be able to satisfy this use-case.

Future Shorthand Extension#

In earlier versions of the Stage 3 proposal, a shorthand for this.# was included:

class Person {
  // declare two private fields
  #first;
  #last;

  constructor(first, last) {
    #first = first;
    #last = last;
  }

  full() {
    return `${#first} ${#last}`;
  }
}

This shorthand proposal was separated from the main proposal in the interest of avoiding unnecessary entanglements, in part because of questions about the semantics of the shorthand when referring to static private fields (another possible future extension).

The authors of the clean slate proposal considered bare -> as a shorthand:

class Person {
  // declare two private fields; var is meant to suggest Instance VARiable
  var first;
  var last;

  constructor(first, last) {
    ->first = first;
    ->last = last;
  }

  full() {
    return `${->first} ${->last}`;
  }
}

However, they observed that this would introduce an ASI hazard and is hostile to a "semicolon-free" style of JavaScript.

They also considered unqualified instance variables:

class Person {
  // declare two private fields; var is meant to suggest Instance VARiable
  var first;
  var last;

  constructor(firstName, lastName) {
    first = firstName;
    last = lastName;
  }

  full() {
    return `${first} ${last}`;
  }
}

In this case, contributors to the thread, including one author of the clean slate proposal observed that this syntax overlaps too strongly with normal lexical variables.

At this time, there is no proposal (speculative or otherwise) for a future shorthand notation for instance variables in the clean slate proposal.

Similarities#

Both the Stage 3 proposal for private fields and the clean slate proposal for instance variables uses a two character operator between the object and the private name: the Stage 3 proposal uses .# and the clean slate proposal uses ->.

The authors of the clean slate proposal have suggested that it's problematic that the Stage 3 proposal specifies .# as two different tokens rather than a single operator. It seems reasonable to change the specification to treat .# as a single token.

Differences in Private State Syntax#

Stage 3 Clean Slate
Declaring #name var name
Read Access this.#name this->name
Write Access this.#name = value this->name = value
Initializers Yes No

Private Instance Methods#

"Private instance methods" provide a mechanism for defining a function inside a class body that has access to private state, but can only be accessed inside of the class body. Like normal methods, "method sugar" provides this as the receiver, and super property access is available.

Stage 3 Proposal: Private Instance Methods#

class Flattener {
  #ids = [];
  #output = [];

  concat(array) {
    this.#concatIds(array);
    this.#concatItems(array);
  }

  #concatIds(array) {
    this.#ids = [...this.#ids, ...array.map(i => i.id)];
  }

  #concatItems(array) {
    this.#output = [...this.#output, ...array];
  }

  finish() {
    return { ids: this.#ids, output: this.#output }
  }
}

Like private instance fields, private instance methods are declared using a # prefix. They are invoked using .#name() syntax, which provides the receiver to the target method as this.

Clean Slate Proposal: Hidden Methods#

Here is the same example written using hidden methods from the clean slate proposal:

class Flattener {
  var ids;
  var output;

  constructor() {
    this->ids = [];
    this->output = [];
  }

  concat(array) {
    this->concatIds(array);
    this->concatItems(array);
  }

  hidden concatIds(array) {
    this->ids = [...this->ids, ...array.map(i => i.id)];
  }

  hidden concatItems(array) {
    this->output = [...this->output, ...array];
  }

  finish() {
    return { ids: this->ids, output: this->output }
  }
}

Hidden methods are declared with a hidden prefix and invoked the same way instance variables are accessed, using the -> operator.

Hidden methods have access to instance variables declared in their enclosing class, and are invoked using ->name() syntax, which provides the receiver to the target method as this.

Differences in Private State and Method Syntax#

Extending the earlier table:

Stage 3 Clean Slate
Declaring Private State #name var name
Reading Private State this.#name this->name
Writing Private State this.#name = value this->name = value
Declaring Private Methods #name() {} hidden name() {}
Invoking Pirvate Methods this.#name() this->name()
Private State Initializers Yes No
  • "Private State" refers to "private instance fields" in the Stage 3 proposal and "instance variables" in the clean slate proposal
  • "Private Methods" refers to "private instance methods" in the Stage 3 proposal and "hidden methods" in the clean slate proposal

Static Blocks#

The clean slate proposal includes an extension that allows classes to contain blocks of code that initialize during class creation. In static blocks, this is the current class being created:

class Person {
  static {
    this.getFirst = obj => obj->first;
    this.getLast = obj => obj->last;
  }

  var first;
  var last;

  constructor(first, last) {
    this->first = first;
    this->last = last;
  }

  full() {
    return `${this->first} ${this->last}`;
  }
}

This feature is a point of agreement between the two proposals. The committee has previously expressed enthusiasm for this extension, and the champions of the Stage 3 proposal have raised it in the past.

The syntax would be identical:

class Person {
  static {
    this.getFirst = obj => obj.#first;
    this.getLast = obj => obj.#last;
  }

  #first;
  #last;

  constructor(first, last) {
    this.#first = first;
    this.#last = last;
  }

  full() {
    return `${this.#first} ${this.#last}`;
  }
}

TL;DR#

The two proposals share a fundamental agreement that the WeakMap perspective of private state is correct. Therefore, both proposals define a mechanism for declaring private state, as well as a syntax that refers to the name of the private state on the right hand side of an apparent infix operator.

The two proposals also agree on the semantics of private instance methods (called hidden methods in the clean slate proposal), including super-property access and sugar for passing the receiver as this. The two proposals differ on the syntax of declaring and invoking those methods.

Other than surface syntax, the primary difference between the two proposals is that the clean slate proposal does not support any way of declaring public class properties, while the Stage 3 proposal supports public fields with initializers.

In the clean slate proposal, users would assign properties in the constructor, as in ES2015. In practice, this means that public fields in the Stage 3 proposal would be defined, while equivalent public properties in the clean slate proposal would be assigned.

This also has implications for TypeScript, which would need to maintain its field syntax as a non-standard extension.

The Stage 3 proposal was designed in concert with a companion decorator proposal, now at Stage 2. The authors of the clean slate proposal are skeptical of decorators, and have not yet sketched out how decorators would work, but have said that they believe decorators are orthogonal to their proposal. That said, because the clean slate proposal intentionally rejects field syntax, it does not have a natural way to decorate properties of a class.

Additionally, because the clean slate proposal rejects initializers and lacks instance variable accessors, several of the highest value use-case identified by the champions of decorators would be impossible.


Repeating (and slightly extending) the earlier table:

Stage 3 Clean Slate
Private State: Declaring #name var name
Private State: Reading this.#name this->name
Private State: Writing this.#name = value this->name = value
Private Methods: Declaring #name() {} hidden name() {}
Private Methods: Invoking this.#name() this->name()
Private State: Initializers? Yes No
Public Fields? Yes, w/ initializers No
  • "Private State" refers to "private instance fields" in the Stage 3 proposal and "instance variables" in the clean slate proposal
  • "Private Methods" refers to "private instance methods" in the Stage 3 proposal and "hidden methods" in the clean slate proposal