By Mark S. Miller (@erights) and Allen Wirfs-Brock (@allenwb)
ECMAScript Class Syntax: A Proposal to clarify orthogonal concerns
Various distinct proposals suggest syntax and semantics for new ClassBody elements. However, up to now, there has not been any guidance for choosing the syntax for new ClassBody extensions. This document presents a common syntactic model for ClassBody member elements. This model can be used to align syntactic design decisions in existing and future proposals.
A "class member" is an object property or private field that is defined as part of a ClassBody. We define class members in terms of three orthogonal dimensions:
- Visibility: Public property or a private field?
- Kind: A variable or a method?
- Placement: Is the member part of the class constructor, the prototype, or each class instances?
Each of these dimensions is encoded in the syntactic structure of a ClassElement. Visibility is encoded by the syntax of the member name: either a PropertyName or a #
BindingIdentifier sequence designating a private field name. Kind is encoded by using either the syntax of an initialized binding or the syntax of a MethodDefinition. Placement is encoding by the initial keyword of the ClassElement: static
prefixes members of the class constructor, own
prefixes instance object members , and the absence of a keyword indicates a member of the prototype object.
A ClassElement is either a single MethodDefinition or a list of initialized bindings.
//A kitchen sink example
class Foo {
//instance members
own x=0, y=0; // two data properties
own #secret; // a private field
// initial value undefined
own *[Symbol.iterator](){yield this.#secret}
// a generator method
own #callback(){} //a private instance method
//class constructor members
static #p=new Set(), q=Foo.#p;
// a private field and a property
// of the class constructor
static get p(){return Foo.#p} //accessor method
//prototype methods
setCallback(f){this.#callback=f}
constructor(s){
this.#secret = s;
}
}
The most important part of this proposal is the concept of syntactic orthogonality. The key idea is that instead of arbitrarily assigning syntax to various ClassElement constructs, there is a set of simple orthogonal syntactic units that represent composable semantic concepts. If a JS programmer understand what static
, #, and the binding list syntactic forms mean then they will understand what static #foo;
or own #bar;
means even if they haven’t specifically been taught about private fields on constructors or own private instance fields. Orthogonality does introduce some syntactic forms that have limited utility. But that’s ok as the orthogonal consistently makes such rarely used cases understandable. The important thing is that all of the allowed cases obey the non-surprising orthogonality meaning of the syntactic tokens. A few special case restrictions are ok, but too many would loose the orthogonality and degenerates into a big bag of arbitrary rules.
- Compatible with existing ECMAScript class definition syntax
- Express private fields and public properties
- Easy to understand
- Hard to misunderstand
- Initialization syntax that does not invite confusion with assignment
- Enable an annotation to annotate many fields and/or properties together
- Express private methods and private static methods
- Be understandable in a "shallow wide" sense
- Invite understanding to become "deeper and narrower"
- Don't cut off sensible choices without good reason
- Avoid combinatorial explosion of ad hoc syntaxes to express many sensible choices
- Preserve investment in existing proposals -- stay similar to them
- Avoid semantic confusion arising from semantics of
private
/public
in other langauges that use a similar syntax for class definitions.
If the initial keyword of a ClassElement is static
then the element defines members on the class/constructor. This is compatible with existing proposed uses of static
.
If the initial keyword of a ClassElement is own
then the element defines members on the instance objects created by the class constructor. This differs from current proposals for initializing state on the instances. Besides the general benefits of orthogonality, using own
for this has specific advantages over these proposals without own
:
- The syntax
x = 9;
looks like an assignment and will be misunderstood as an assignment. The syntaxown x = 9;
looks like a variable declaration and initialization, making DefineProperty semantics less surprising. - Grouping multiple declarations enables them to be annotated together. However, several people said they find the syntax
x = 9, y, z = 10;
confusing in this position. The syntaxown x = 9, y, z = 10;
is not surprising, again, because it follows the existing syntactic pattern of variable declarations and initializations. - The syntax supports the definition of method forms on instance objects.
In the absence of either the static
or own
initial keywords, the element defines members on the prototype object. When used with MethodDefinition class elements, this is compatible with existing syntax.
Used with the binding list form of class elements , this would recreate the confusions we just enumerated above with x = 9, y, z = 10;
together with the further confusion that this would be initialized on the prototype. Also while the conciseness of no leading placement keyword makes sense for prototype method definitions (which are the most commonly used form of ClassElement) it makes little sense for rarely needed prototype data properties or prototype level private fields. Instead, we disallow the binding list forms of class elements that do not have either a static
or own
initial keyword. For those writing code, this violation of orthogonality may be a rude surprise. But for those reading code which is not statically rejected, there is no such violation or rude surprise.
For reasons discussed below, we also disallow prototype placement of private methods.
In the existing private state proposal a class element prefixed with a #
sigil defines a private field of a class instance. The existing private state proposal postpones the issue of how one would express private instance methods, private prototype methods or private static methods. In this proposal any class element that defines a member name that is prefixed with a #
sigil defines a private field. The instance, prototype, or class/constructor placement is orthogonally determined based upon the placement keyword. static
MethodDefinition, where the member name in the MethodDefinition is prefixed with #
, is a method accessed as a private field of the class/constructor object, i.e., the method is the initial value of a private field. Similarly,own
MethodDefinition containing a #
sigil is a method accessed as a private field of instance objects created by the class/constructor. Each such private method field of instance objects is initialized with a new function object as part of the instance initialization process. (Or perhaps the initial function objects are determined at class definition time and initially shared by all instances. That decision is in the realm of the private state proposal.)
In the WeakMap-like way of defining private state, these additional cases are specified the same way: the private names are in scope over the same body of code. But rather than using instances as the keys of the weakmap-like collection named by those names, the key would be either the prototype or the class/constructor. Of course, implementations can implement by any means that is not observably different from that.
Each member is defined either via a MemberDefinition or as an element of a member binding list. While both sorts of members are normally mutable we characterize members defined via binding lists as "variables" because they are typically used to store state rather than to invoke object behavior.
A MethodDefinition can define a normal method, a generator method, an async function method, an accessor, or the constructor. Accessors members are not about the initial value of the member, but rather about the nature of the member itself. This only makes sense for properties. A private accessor field is not meaningful and hence is not allowed.
A "private method" is simply a private field that is initialized using a method definition. The ability to define a private methods on a class prototype initially sounds like useful functionality. However, when the proposed private field access semantics is examined, prototype placement of private methods turns out to be pretty useless. The basic problem is that there is no convenient way to reference such methods as instance objects don't inherit access to private fields placed on the prototype. Consider:
class X {
#helper() {}; //private method on prototype
leader() {
this.#helper(); //error because instances don't have a #helper field
#helper(); //error because this means the same as this.#helper()
this.__proto__.#helper(); //error if invoked on a subclass instance
X.prototype.#helper(); // works (assuming no rewiring) but ugly
//better to use a static private method:
//static #helper(self) {};
// X.#helper(this);
}
}
Class scoped lexical function declarations appear to be a better solution for most private method on prototype use cases. These will be presented in a separate proposal.
A member definition is either an element of a data member binding list or one of the MethodDefinition forms. Most of the orthogonal combinations of method placement and visibility permit the use of any member definition form. The forms that are syntactically disallowed are summarized in the following table.
position/visibility | public property | private field |
---|---|---|
own | ||
static | ||
(prototype) |
###Proposed Syntax
The following is the proposed syntactic changes introduced by this proposal. Grammar parameter which are not directly relevant to this proposal have been elided and will have to be reintroduced for the final specification text.
14.5 Class Definition Changes
ClassElement : MemberElement Annotation MemberElement ;
Annotation is defined by the Decorators Proposal.
MemberElement : ConstructorElement PrototypeElement InstanceElement ConstructorElement : static MemberBindingList ; static MethodDefinition[Private] PrototypeElement : MethodDefinition InstanceElement : own MemberBindingList ; own MethodDefinition[Private] MemberBindingList : MemberBinding[Private] MemberBindingList , MemberBinding[Private] MemberBinding[Private] : PropertyName Initializeropt [+Private] PrivateBindingIdentifier Initializeropt PrivateBindingIdentifier : # BindingIdentifier
14.3 Method Definitions Changes
MethodDefinition[Private] : MethodName[?Private] ( UniqueFormalParameters ) { FunctionBody } GeneratorMethod[?Private] AsyncMethod[?Private] get PropertyName ( ) { FunctionBody } set PropertyName ( PropertySetParameterList ) { FunctionBody } MethodName[Private] : PropertyName [+Private] PrivateBindingIdentifier
GeneratorMethod[Private]: * MethodName[?Private] ( UniqueFormalParameters ) { GeneratorBody }
14.6 Async Function Definitions Changes
AsyncMethod[Private]: async [no LineTerminator here] MethodName[?Private] ( UniqueFormalParameters ) { AsyncFunctionBody }