ECMAScript proposal: private static methods and accessors in classes

[2020-06-24] dev, javascript, es proposal, js classes
(Ad, please don’t block)

This blog post is part of a series on new members in bodies of class definitions:

  1. Public class fields
  2. Private class fields
  3. Private prototype methods and getter/setters in classes
  4. Private static methods and getter/setters in classes

This post explains private static methods and accessors in classes, as described in the ECMAScript proposal “Static class features” by Shu-yu Guo and Daniel Ehrenberg.

Overview: private static methods and accessors  

The following kinds of private static methods and accessors exist:

class MyClass {
  static #staticPrivateOrdinaryMethod() {}
  static * #staticPrivateGeneratorMethod() {}

  static async #staticPrivateAsyncMethod() {}
  static async * #staticPrivateAsyncGeneratorMethod() {}
  
  static get #staticPrivateGetter() {}
  static set #staticPrivateSetter(value) {}
}

An example with private static methods  

The following class has a private static method .#createInternal():

class Point {
  static create(x, y) {
    return Point.#createInternal(x, y);
  }
  static createZero() {
    return Point.#createInternal(0, 0);
  }
  static #createInternal(x, y) {
    const p = new Point();
    p.#x = x; // (A)
    p.#y = y; // (B)
    return p;
  }
  #x;
  #y;
}

This code shows the key benefit of private static methods, compared to external (module-private) helper functions: They can access private instance fields (line A and line B).

Pitfall: Don’t access private static constructs via this  

Accessing public static constructs via this lets us avoid redundant use of class names. It works because those constructs are inherited by subclasses. Alas, we can’t do the same for private static constructs:

class SuperClass {
  static #privateData = 2;
  static getPrivateDataViaThis() {
    return this.#privateData;
  }
  static getPrivateDataViaClassName() {
    return SuperClass.#privateData;
  }
}
class SubClass extends SuperClass {
}

// Works:
assert.equal(SuperClass.getPrivateDataViaThis(), 2);

// Error:
assert.throws(
  () => SubClass.getPrivateDataViaThis(), // (A)
  {
    name: 'TypeError',
    message: 'Cannot read private member #privateData from an object whose class did not declare it',
  }
);

// Work-around for previous error:
assert.equal(SubClass.getPrivateDataViaClassName(), 2);

The problem in line A is that this points to SubClass which does not have the private field .#privateData. For more information, see the blog post “Private class fields”.

The specification for private static fields, methods, and accessors  

The foundations laid by private prototype methods  

Support for static methods and accessors is based on mechanisms that were introduced for prototype methods and accessors (more information).

We will only examine private methods, but everything we discover also applies to private getters and setters.

Storing private methods  

Consider a method .#privateMethod() that is created “inside” an object HomeObj. This method is stored externally, in a specification data structure called private name. Private names are also used to represent other private class elements. They are looked up via private environments, which map identifiers to private names and exist next to environments for variables. Private environments are explained later.

In this case, the private name has the following slots:

  • .[[Description]] = "#privateMethod"
  • .[[Kind]] = "method"
  • .[[Brand]] = HomeObj
  • .[[Value]] points to the method object (a function)

The brand of a private method is the object it was created in.

The private brands of objects  

Each object Obj has an internal slot Obj.[[PrivateBrands]] which contains the brands of all methods that can be invoked on Obj. There are two ways in which elements are added to the private brands of an object:

  • When a class C is new-invoked, it adds C.prototype to the private brands of this. That means that C’s private prototype methods (whose brand is C.prototype) can be invoked on this.

  • If a class C has private static methods, C is added to the private brands of C. That means that C’s private static methods (whose brand is C) can be invoked on C.

Private brands vs. prototype chains  

Therefore, the private brands of an object are related to the prototype chain of an object. Why has this mechanism been introduced if it is so similar?

  • Private methods are designed to be actually private and to have integrity. That means that they shouldn’t be affected by outside changes. If the private brands of an object were determined by its prototype chain, we could enable or disable private methods by changing the chain. We could also observe part of the executions of private methods by observing the traversal of the prototype chain via a Proxy.

  • This approach guarantees that, when we invoke a private method on an object, its private fields also exist (as created by constructors and evaluations of class definitions). Otherwise, we could use Object.create() to create an instance without private instance fields to which we could apply private methods.

Lexical scoping of private identifiers  

Execution contexts now have three environments:

  • LexicalEnvironment points to the environment for let and const (block scoping).
  • VariableEnvironment points to the environment for var (function scoping).
  • PrivateEnvironment points to an environment that maps identifiers prefixed with # to private name records.

Functions now have two lexical environments:

  • [[Environment]] refers to the environment of the scope in which the function was created.
  • [[PrivateEnvironment]] refers to the environment with the private names that was active when the function was created.

The operation ClassDefinitionEvaluation temporarily changes the current execution context for the body of a class:

  • The LexicalEnvironment is set to classScope, a new declarative environment.
  • The PrivateEnvironment is set to classPrivateEnvironment, a new declarative environment.

For each identifier dn of the PrivateBoundIdentifiers of the class body, one entry is added to the EnvironmentRecord of classPrivateEnvironment. The key of that entry is dn, the value is a new private name.

The specification for private static constructs  

The following parts of the runtime semantics rule ClassDefinitionEvaluation are relevant for static private constructs (F refers to the constructor):

  • Step 28.b.i: Perform PropertyDefinitionEvaluation(F, false) for each static ClassElement

    • Step 28.d.ii: If the result is not empty, it is added to a list staticFields (so that it can be attached to F later).
  • Step 33.a: If there is a static method or accessor P in PrivateBoundIdentifiers of ClassBody and P’s .[[Brand]] is F: Execute PrivateBrandAdd(F, F). Intuitively, that means: object F can be receiver of methods stored in object F

  • Step 34.a: For each fieldRecord in staticFields: DefineField(F, fieldRecord)

The internal representation of private static methods and private instance fields illustrated in JavaScript  

Let’s look at an example. Consider the following code from earlier in this post:

class Point {
  static create(x, y) {
    return Point.#createInternal(x, y);
  }
  static createZero() {
    return Point.#createInternal(0, 0);
  }
  static #createInternal(x, y) {
    const p = new Point();
    p.#x = x;
    p.#y = y;
    return p;
  }
  
  #x;
  #y;

  toArray() {
    return [this.#x, this.#y];
  }
}

Internally, it is roughly represented as follows:

{ // Begin of class scope

  class Object {
    // Maps private names to values (a list in the spec).
    __PrivateFieldValues__ = new Map();
    
    // Prototypes with associated private members
    __PrivateBrands__ = [];
  }

  // Private name
  const __x = {
    __Description__: '#x',
    __Kind__: 'field',
  };
  // Private name
  const __y = {
    __Description__: '#y',
    __Kind__: 'field',
  };

  class Point extends Object {
    static __PrivateBrands__ = [Point];
    static __PrivateBrand__ = Point.prototype;
    static __Fields__ = [__x, __y];

    static create(x, y) {
      PrivateBrandCheck(Point, __createInternal);
      return __createInternal.__Value__.call(Point, x, y);
    }
    static createZero() {
      PrivateBrandCheck(Point, __createInternal);
      return __createInternal.__Value__.call(Point, 0, 0);
    }

    constructor() {
      super();
      // Setup before constructor
      InitializeInstanceElements(this, Point);

      // Constructor itself is empty
    }
    toArray() {
      return [
        this.__PrivateFieldValues__.get(__x),
        this.__PrivateFieldValues__.get(__y),
      ];
    }

    dist() {
      PrivateBrandCheck(this, __computeDist);
      __computeDist.__Value__.call(this);
    }
  }

  // Private name
  const __createInternal = {
    __Description__: '#createInternal',
    __Kind__: 'method',
    __Brand__: Point,
    __Value__: function (x, y) {
      const p = new Point();
      p.__PrivateFieldValues__.set(__x, x);
      p.__PrivateFieldValues__.set(__y, y);
      return p;
    },
  };
} // End of class scope

function InitializeInstanceElements(O, constructor) {
  if (constructor.__PrivateBrand__) {
    O.__PrivateBrands__.push(constructor.__PrivateBrand__);
  }
  const fieldRecords = constructor.__Fields__;
  for (const fieldRecord of fieldRecords) {
    O.__PrivateFieldValues__.set(fieldRecord, undefined);
  }
}

function PrivateBrandCheck(obj, privateName) {
  if (! obj.__PrivateBrands__.includes(privateName.__Brand__)) {
    throw new TypeError();
  }
}

Acknowledgements: