ECMAScript class property access expressions

Class access expressions seek to simplify access to static members of a class as well as provide access to static members of a class when that class is unnamed:

class C {
  static f() { ... }
  
  g() {
    class.f();
  }
}

Status

Stage: 1
Champion: Ron Buckton (@rbuckton)

For detailed status of this proposal see TODO, below.

Authors

  • Ron Buckton (@rbuckton)

Motivations

Today, ECMAScript developers can write classes with static members that can be accessed in one of two ways:

  • Via the class name:
    class C {
      static x() {}
      y() { C.x(); }
    }
    
  • Via this in a static member:
    class C {
      static x() {}
      static y() { this.x(); }
    }
    

However, there is no easy mechanism to access class statics in a class that has no name:

export default class {
  static x() { }
  y() { 
    this.constructor.x(); // Not actually guaranteed to be the correct `x`.
  }
}


const x = class { 
  static x() { }
  y() {
    this.constructor.x(); // Not actually guaranteed to be the correct `x`.
  }
}

It’s not guaranteed to be the correct x in the above examples, because you could be accessing an overridden member of a subclass.

Also, with current proposal for private static fields and methods, its very easy to run into errors at runtime when using this in a static method:

class C {
  static #x() {}
  static y() { this.#x(); }
}
class D extends C {}
D.y(); // TypeError

Syntax

// from a non-static method
class C {
  static f() { }
  g() {
    class.f();
    class["f"]();
  }
}

// from a static method
class C {
  static f() { }
  static g() {
    class.f();
    class["f"]();
  }
}

// with static private members
class C {
  static #f() {}
  static g() {
    class.#f();
  }
}

Semantics

  • Function Environment Records have a new field in Table 15:
    Field Name Value Meaning
    [[InitialClassObject]] Object | undefined If the associated function has class property access and is not an ArrowFunction, [[InitialClassObject]] is the class that the function is bound to as a method. The default value for [[InitialClassObject]] is undefined.
  • Function Environment Records have a new method in Table 16:
    Method Purpose
    GetClassBinding() Return the object that is the base for class property access bound in this Environment Record.
  • ECMAScript Function Objects have a new internal slot in Table 27:
    Interanl Slot Type Description
    [[InitialClassObject]] Object | undefined If the function has class property access, this is the object where class property lookups begin.
  • During ClassDefinitionEvaluation, the class constructor (F) is set as the [[InitialClassObject]] on the method.
  • During NewFunctionEnvironment, the [[InitialClassObject]] is copied from the method (F) to envRec.[[InitialClassObject]].
  • Arrow functions use the [[InitialClassObject]] of their containing lexical environment (similar to super and this).
  • When evaluating ClassProperty: `class` `.` IdentifierName we return a new Reference with the following properties:
    • The referenced name component is the StringValue of IdentifierName.
    • The base value component is the [[InitialClassObject]] of GetThisEnvironment().
  • When evaluating ClassProperty: `class` `[` Expression `]` we return a new Reference with the following properties:
    • The referenced name component is the result of calling ?ToPropertyKey on the result of calling GetValue on the result of evaluating Expression.
    • The base value component is the [[InitialClassObject]] of GetThisEnvironment().
  • When evaluating ClassProperty: `class` `.` PrivateIdentifier we perform the following steps:
    1. Let fieldNameString be the StringValue of PrivateIdentifier.
    2. Let bv be the [[InitialClassObject]] of GetThisEnvironment().
    3. Return ?MakePrivateReference( bv, fieldNameString )
  • class bindings are only available in the following declarations:
  • class bindings are not valid in any of the following declarations:
    • A FunctionDeclaration or FunctionExpression.
    • A method of an object literal.
    • An accessor of an object literal.
    • Any execution context not nested within a lexical class declaration, including module or global scope.
    • NOTE: This aligns with the behavior of super.

Examples

Property Access

In class methods or the class constructor, getting the value of class.x always refers to the value of the property x on the containing lexical class:

class Base {
    static f() {
        console.log(`this: ${this.name}, class: ${class.name})`);
    }
}
class Sub extends Base {
}

Base.f();                           // this: Base, class: Base
Sub.f();                            // this: Sub, class: Base
Base.f.call({ name: "Other" });     // this: Other, class: Base

This behavior provides the following benefits:

  • Able to reference static members of the containing lexical class without needing to repeat the class name.
  • Able to reference static members of an anonymous class declaration or expression:
    export default class {
        static f() { ... }
        g() { class.f(); }
    }
    

Property Assignment

In class methods or the class constructor, setting the value of class.x always updates the value of the property x on the containing lexical class:

function print(F) {
    const { name, x, y } = F;
    const hasX = F.hasOwnProperty("x") ? "own" : "inherited";
    const hasY = F.hasOwnProperty("y") ? "own" : "inherited";
    console.log(`${name}.x: ${x} (${hasX}), ${name}.y: ${y} (${hasY})`);
}

class Base {
    static f() {
        this.x++;
        class.y++;
    }
}

Base.x = 0;
Base.y = 0;

class Sub extends Base {
}

print(Base);                        // Base.x: 0 (own), Base.y: 0 (own)
print(Sub);                         // Sub.x: 0 (inherited), Sub.y: 0 (inherited)

Base.f();

print(Base);                        // Base.x: 1 (own), Base.y: 1 (own)
print(Sub);                         // Sub.x: 1 (inherited), Sub.y: 1 (inherited)

Sub.f();

print(Base);                        // Base.x: 1 (own), Base.y: 2 (own)
print(Sub);                         // Sub.x: 2 (own), Sub.y: 2 (inherited)

Base.f();

print(Base);                        // Base.x: 2 (own), Base.y: 3 (own)
print(Sub);                         // Sub.x: 2 (own), Sub.y: 3 (inherited)

This behavior provides the following benefits:

  • Assignments always occur on the current lexical class, which should be unsurprising to users.

Method Invocation

Invoking class.x() in a method, an initializer, or in the constructor uses the value of containing lexical class as the receiver:

class Base {
    static f() {
        console.log(`this.name: ${this.name}, class.name: ${class.name})`);
    }
    static g() {
        class.f();
    }
    h() {
        class.f();
    }
}
class Sub extends Base {
}

Base.g();                           // this: Base, class: Base
Sub.g();                            // this: Sub, class: Base
Base.g.call({ name: "Other" });     // this: Other, class: Base

let b = new Base();
let s = new Sub();
b.h();                              // this: Base, class: Base
s.h();                              // this: Sub, class: Base
b.h.call({ name: "Other" });        // this: Other, class: Base

Invalid usage

class C {
  static x = 1;
  constructor() {
    function f() {
      return class.x; // function has no access to `class.`
    }
    f(); // throws TypeError

    const obj = {
      method() {
        return class.x; // method of object literal has no access to `class`.
      }
    };
    obj.method(); // throws TypeError
  }
}

Grammar

MemberExpression[Yield, Await] :
  ClassProperty[?Yield, ?Await]

ClassProperty[Yield, Await] :
  `class` `[` Expression[+In, ?Yield, ?Await] `]`
  `class` `.` IdentifierName
  `class` `.` PrivateIdentifier

Relationship to Other Proposals

Class Fields

This proposal can easily align with the current class fields proposal, providing easier access to static fields without unexpected behavior:

class Base {
    static counter = 0;
    id = class.counter++;       // `Base` is used as `this`
}

class Sub extends Base {
}

console.log(new Base().id);     // 0
console.log(new Sub().id);      // 1
console.log(Base.counter);      // 2
console.log(Sub.counter);       // 2

Class Private Fields

In addition to private methods, this proposal can also align with the current proposals for class private fields, providing access to class static private state without introducing TypeErrors due to incorrect this:

class Base {
    static #counter = 0;
    static increment() {
        return class.#counter++;
    }
}

class Sub extends Base {
}

console.log(Base.increment());  // 0
console.log(Sub.increment());   // 1
console.log(Base.increment());  // 2

Class Private Methods

One of the benefits of class access expressions is that they guarantee the correct reference is used when accessing static private members. Special care must be taken, however, when invoking private and non-private static methods using class access expressions, as the this binding within the invoked method will be the lexical class declaration:

class Base {
  static #counter = 0;
  static #increment() {
    class.#counter++;
    this.printCounter();
  }
  static doIncrement() {
    class.#increment();
  }
  static printCounter() {
    console.log(class.#counter);
  }
}
class Sub extends Base {
  static printCounter() {
    console.log("Custom Counter");
    super.printCounter();
  }
}

Base.doIncrement(); // prints: 1
Sub.doIncrement(); // prints: 2

In the example above, Sub's overriden printCounter is never invoked. Such method calls would need to be rewritten:

// option 1:
class Base {
  ...
  static #increment() {
    ...
  }
  static doIncrement() {
    class.#increment.call(this);
  }
}

// option 2:
class Base {
  ...
  static #increment(C) {
    ...
    C.printCounter();
  }
  ...
  static doIncrement() {
    class.#increment(this);
  }
}

This is due to the fact that class. in this example is essentially a substitute for Base., therefore Base becomes the receiver in these method calls.

TODO

The following is a high-level list of tasks to progress through each stage of the TC39 proposal process:

Stage 1 Entrance Criteria

  • Identified a "champion" who will advance the addition.
  • Prose outlining the problem or need and the general shape of a solution.
  • Illustrative examples of usage.
  • High-level API.

Stage 2 Entrance Criteria

Stage 3 Entrance Criteria

Stage 4 Entrance Criteria

  • Test262 acceptance tests have been written for mainline usage scenarios and merged.
  • Two compatible implementations which pass the acceptance tests: [1], [2].
  • A pull request has been sent to tc39/ecma262 with the integrated spec text.
  • The ECMAScript editor has signed off on the pull request.