Lightweight Protected Fields
Introduction and Problem
Something I have seen both in TC39 proposals, on Twitter and various other places is that there is quite a lot of interest in some form of protected fields.
Now something I've know about for a while is that we actually have a pretty close analogue by way of the revealing constructor pattern, for example consider the following subclass of Promise
:
class SubPromise extends Promise {
#resolve;
#reject;
constructor() {
// This boilerplate is annoying, we would
// ideally be able to avoid this sort've thing
let resolve, reject;
super((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
this.#resolve = resolve;
this.#reject = reject;
}
resolveWith10() {
this.#resolve(10);
}
get reject() {
return this.#reject;
}
}
In this example #resolve
/#reject
are essentially protected state, the superclass provided these things to whom created the object and we stored them privately, they aren't accessible to outside code so we have effectively provided protected state to a subclass via a pattern.
However this pattern suffers from a number of ergonomic issues that make it considerably worse than protected
fields in more common languages.
Some of these we can see from the example:
- We need to provide everything via a callback in the constructor
- Because
this
isn't accessible beforesuper()
we can't just dosuper((resolve) => this.#resolve = resolve)
; becausethis
ISN'T AVAILABLE YET to the closure- This means we need to add tedious boilerplate variables
However additional issues exist as well that the example doesn't demonstrate:
- Evolving our protected state can be difficult as we need to thread everything through the revealing constructor
- Exposing things to the revealing constructor creates a lot of boilerplate for the superclass effectively duplicating methods everywhere
- For example:
class Task { #state = "running"; constructor(init) { init({ // This object is literally just // duplicating SOME of our private fields // to make them "protected" get complete() { return this.#complete; }, addSubTask: this.#addSubTask.bind(this), }); } get #complete() { return this.#state === "done"; } #addSubTask() { // Add a subtask somehow } }
- We have to create many instances of objects to pass them as protected, for example with the above example we need a bound copy of
this.#addSubTask
for every instance irrespective of whether it is used or not, this may be a GC hazard for subclasses which store the entire controller object
Solution
Now I believe this pattern can be canonicalized into a minimal protected fields idea, essentially what I propose is two things, first we have a protected #field
which declares a private field in a superclass as protected, secondly we allow access to these fields through super.#field
. I'll explain the rationale for the design further down, but first let's look at an example:
class Example {
// This declares the field as accessible to subclasses
// via super.#value
protected #value = 10;
printValue() {
// From within the class the protected field acts
// identically to a private field
console.log(this.#value);
}
}
class Subclass extends Example {
constructor() {
// First we intialize the superclass
super();
// Now protected state is accessible through
// super like other super-class properties
console.log(super.#value); // 10
super.#value = 20;
super.printValue(); // 20
}
}
With this design methods, and accessors can be used directly as per usual:
class Example {
protected #method() {
return Math.random();
}
protected get #getter() {
return "fizzbuzz";
}
}
class Subclass extends Example {
constructor() {
super();
super.#method(); // A random number
super.#getter; // fizzbuzz
}
}
Rationale for above design decisions
Okay so now that we've seen the idea, you might have some questions about the rationale for some decisions.
In particular I imagine people will ask why do we access the protected state by super.#protectedField
rather than this.#protectedField
. There are two reasons for this, firstly the target object of super
is not an instance of the subclass, so it does not actually have fields of the subclass, this makes this
make less sense. But secondly and more importantly is that is denies access to protected
fields from other subclasses, for example consider this example considering if Promise
used protected fields:
class Promise {
protected #resolve(value) {
/* Resolve the promise */
}
protected #reject(reason) {
/* Reject the promise */
}
}
class UncoverProtectedState extends Promise {
revealPromiseResolve() {
// You might think we can just use super.#resolve
// to reveal the #resolve method of any promise for example
return super.#resolve;
}
}
// But actually this doesn't work, because super.#resolve
// can only be accessed from instance methods (as its part of
// "super" NOT "this") it can only access its OWN INSTANCE
const promise = new Promise(() => {});
const uncoverProtectedState = new UncoverProtectedState();
// People would imagine attacking the protected state like this
// but is DOESN'T WORK, because super is effectively bound
// thanks to its home object, so while this value does return
// a value IT ISN'T promise.#resolve
// it's uncoverProtectedState.#resolve
const resolve = uncoverProtectedState.revealPromiseResolve.call(promise);
const resolve2 = uncoverProtectedState.revealPromiseResolve.call(new Promise(() => {}));
const resolve3 = uncoverProtectedState.revealPromiseResolve();
// All these resolves are thus exactly the same
// they're just the #resolve for the uncoverProtectedState object
// itself
console.log(resolve1 === resolve2); // true
console.log(resolve1 === resolve3); // true
Okay so another thing people might wonder is why use private field syntax? The answer is fairly simple, and basically the same reason as private fields use that syntax, it won't collide with public fields and otherwise behaves like private fields.
Open Gaps in the Design
So the design above I believe would suffice for basically all cases of protected fields for a SINGLE LAYER of subclassing. However what I haven't any particular opinion on is how should protected fields inherit.