A TC39 follow-on proposal to extend Private syntax to static fields/methods in classes
The main motivation to allowing static private fields/methods inside classes is to allow common refactorings. This means both switching from public to private and extracting common code into static helper methods should be supported with minimal fuss. For a full write up on the usecase, please see Example of the utility of static private methods.
But, static private cannot be implemented prefectly, as demonstrated by the subclass footgun. Unlike static public, static privates are not inherited through the prototype chain, and resolving the property through object-resolved bindings (eg, this.#x
) breaks the brand checking. Further, there is no clear way to inherit static privates without considerable hacks.
One suggested path forward is to rewrite static private in terms of lexically scoped variables and methods, to which they're analagous. This lead to several proposals, including local
lexical bindings, Classes 1.1, and static initializer blocks.
However, we never considered just making the static private lexical, and keeping everything else the same. Until now.
Static private methods are now lexically-resolved variables and functions, very similar to the local
lexical bindings proposal. But, instead of defining a new syntax, we continue to use the static
keyword and the #
private syntax. Further, static private are forbidden (via early syntax errors) from being used in object-resolved bindings, and instance privates are forbidden from using lexical-resolved bindings.
class Example {
static #staticField = 1;
static #staticMethod() {}
#instanceField = 1;
#instanceMethod() {}
constructor() {
// Static fields are lexically-resolved
#staticField;
#staticMethod();
// Instance fields are object-resolved
this.#instanceField;
this.#instanceMethod();
/**
// Static fields may not be object-resolved
this.#staticField // Syntax Error!
this.#staticMethod() // Syntax Error!
// Instance fields may not be lexically-resolved
#instanceField // Syntax Error!
#instanceMethod() // Syntax Error!
**/
}
}
Core usecase with static private proposal
export const registry = new JSDOMRegistry();
export class JSDOM {
#createdBy;
#registerWithRegistry() {
// ... elided ...
}
async static fromURL(url, options = {}) {
normalizeFromURLOptions(options);
normalizeOptions(options);
const body = await getBodyFromURL(url);
return #finalizeFactoryCreated(new JSDOM(body, options), "fromURL");
}
static fromFile(filename, options = {}) {
normalizeOptions(options);
const body = await getBodyFromFilename(filename);
return #finalizeFactoryCreated(new JSDOM(body, options), "fromFile");
}
static #finalizeFactoryCreated(jsdom, factoryName) {
jsdom.#createdBy = factoryName;
jsdom.#registerWithRegistry(registry);
return jsdom;
}
}
Since static private methods are lexically-resolved and invoked, it doesn't make sense to provide a this
context. You may, however, use Function.p.call
to provide a this
.
Additionally, super
is defined just like any other static method, in terms of the constructor's __proto__
.
class Base {
static x = 1;
}
class Example extends Base {
static #staticMethod() {
return [this, super.x];
}
constructor() {
#staticMethod(); // => [undefined, 1]
#staticMethod.call({}); // => [{}, 1]
}
}
Static initializer blocks solve the core usecase by allowing arbitrary code to run in the scope of the class during class creation.
let getter, setter;
class C {
#x;
static {
// Arbitrary code can be run here.
foo(bar, baz);
1 + 1;
// Private helper functions may be hoisted here.
getter = (obj) => obj.#x;
setter = (obj, value) => obj.#x = value;
}
}
Core usecase with static initializer blocks
export const registry = new JSDOMRegistry();
let finalizeFactoryCreated;
export class JSDOM {
#createdBy;
#registerWithRegistry() {
// ... elided ...
}
async static fromURL(url, options = {}) {
normalizeFromURLOptions(options);
normalizeOptions(options);
const body = await getBodyFromURL(url);
return finalizeFactoryCreated(new JSDOM(body, options), "fromURL");
}
static fromFile(filename, options = {}) {
normalizeOptions(options);
const body = await getBodyFromFilename(filename);
return finalizeFactoryCreated(new JSDOM(body, options), "fromFile");
}
static {
finalizeFactoryCreated = function(jsdom, factoryName) {
jsdom.#createdBy = factoryName;
jsdom.#registerWithRegistry(registry);
return jsdom;
}
}
}
While this does solve the usecase, it's not particularly ergonomic. Keeping an uninitialized list of helper function variables at the beginning of the class feels a bit like a code smell. There are valid reasons to use this kind of pattern (Promises and the resolve
binding come to mind).
Still, I would much rather declare a variable and initialize it in the same stroke (let x; /* ... */ x = 1
vs let x = 1; /* ... */
).
Local lexical bindings allow you to define functions and variables inside the ClassBody
.
class C {
#x;
function getter(obj) {
return obj.#x;
}
function setter(obj, value) {
obj.#x = value;
}
}
Core usecase with static initializer blocks
export const registry = new JSDOMRegistry();
export class JSDOM {
#createdBy;
#registerWithRegistry() {
// ... elided ...
}
async static fromURL(url, options = {}) {
normalizeFromURLOptions(options);
normalizeOptions(options);
const body = await getBodyFromURL(url);
return finalizeFactoryCreated(new JSDOM(body, options), "fromURL");
}
static fromFile(filename, options = {}) {
normalizeOptions(options);
const body = await getBodyFromFilename(filename);
return finalizeFactoryCreated(new JSDOM(body, options), "fromFile");
}
function finalizeFactoryCreated(jsdom, factoryName) {
jsdom.#createdBy = factoryName;
jsdom.#registerWithRegistry(registry);
return jsdom;
}
}
This proposal suffers from confusing syntax. It's not entirely clear from the syntax that these are not instances fields/methdos. It's also not clear why only declarations are supported and not arbitrary expressions.
The Classes 1.1 proposal is a re-imaging the current fields syntax to create a "maximally minimal syntax".
class C {
var x;
hidden getter() {
return this->x;
}
hidden setter(value) {
this->x = value;
}
static {
// Arbitrary code here
}
}
Core usecase with static initializer blocks
export const registry = new JSDOMRegistry();
export class JSDOM {
#createdBy;
#registerWithRegistry() {
// ... elided ...
}
async static fromURL(url, options = {}) {
normalizeFromURLOptions(options);
normalizeOptions(options);
const body = await getBodyFromURL(url);
return this->finalizeFactoryCreated(new JSDOM(body, options), "fromURL");
}
static fromFile(filename, options = {}) {
normalizeOptions(options);
const body = await getBodyFromFilename(filename);
return this->finalizeFactoryCreated(new JSDOM(body, options), "fromFile");
}
hidden finalizeFactoryCreated(jsdom, factoryName) {
jsdom.#createdBy = factoryName;
jsdom.#registerWithRegistry(registry);
return jsdom;
}
}
This is essentially a combination of the previous two proposals. For the
core usecase, the static block initializer is largely unnecessary since
we have hidden
lexical functions. Unfortunately, this lexical
functions have very confusing semantics. The left-hand side of the
->
(pointer arrow) can be anything (eg, "hello"->x
, which will
call the hidden lexical x
with "hello"
as the this
context), but
the function is resolved lexically. This totally breaks from the obj.x
object-resolve lookup JavaScript developer familiar with, and the normal
lexically-resolved x
lookup.