As much as I utterly hate the recently accepted class fields proposal, it's still somewhat close to what I was hoping for when they started down this path. So instead of sitting back complaining, I've been thinking about how to take the mess that is and make something I consider useful out of it. In my opinion, there's 3 major issues with class fields/private fields:
- Private fields (internal slots) footgun Proxy
- Public fields footgun inheritance
- The utter absence of a protected implementation.
Since I would love to have the speed advantages that come along with using private fields and the documentation advantages that come with public fields, but without the footguns, I've decided to solve the first 2 issues with a couple of simple rules.
- Create (in the constructor) a read only property (usually
self
) on the instance with valuethis
. Only access private members usingself
. - Define all class public data properties as accessors to private properties.
Both of these rules involve a bit of boilerplate. But that got me thinking about protected again. Turns out the strange way public fields are implemented and the overly aggressive way private fields are implemented gives us a pattern that a transpiler can use to implement protected support. Ignoring what the input syntax would look like for a moment, here's an example of a protected transpiled result.
const Base = (function() {
const ID = Symbol("Base");
class Base {
#data = 42;
//Protected accessors
//Using a field and arrow functions here is critical
[ID] = {
data: {
get: () => { return this.self.#data; },
set: (v) => { this.self.#data = v; }
}
}
//A calculated function provided by the transpiler
_initProt = (target) => {
if (target === Base) {
console.log(`Deleting protected information...`);
delete this[ID];
}
delete this._initProt;
}
constructor() {
Object.defineProperty(this, "self", {value: this});
this._initProt(new.target);
}
test(_class = Base) {
console.log(`Instance of ${_class.name} has protected? ${this.hasOwnProperty(_class.classID)}`);
console.log(`Base protected data: ${this.self.#data}`);
}
};
Object.defineProperty(Base, "classID", {value: ID});
return Base;
})();
const Derived = (function() {
const ID = Symbol("Derived");
class Derived extends Base {
#protected = {};
_initProt = (target) => {
if (this.hasOwnProperty(Base.classID)) {
let prot = this[Base.classID];
for (let key in prot) {
console.log(`Found protected property: "${key}"...`);
Object.defineProperty(this.self.#protected, key, prot[key]);
}
if (this[ID]) {
Object.setPrototypeOf(this[ID], this[Base.classID]);
}
else {
this[ID] = this[Base.classID];
}
delete this[Base.classID];
}
if (target === Derived) {
delete this[ID];
}
delete this._initProt;
}
constructor() {
super();
Object.defineProperty(this, "self", {value: this});
this._initProt(new.target);
++this.self.#protected.data;
}
test() {
super.test();
super.test(Derived);
console.log(`Derived protected data: ${this.self.#protected.data}`);
}
}
Object.defineProperty(Derived, "classID", {value: ID});
return Derived;
})();
const Sibling = (function() {
const ID = Symbol("Sibling");
class Sibling extends Base {
#protected = {};
_initProt = (target) => {
if (this.hasOwnProperty(Base.classID)) {
let prot = this[Base.classID];
for (let key in prot) {
console.log(`Found protected property: "${key}"...`);
Object.defineProperty(this.self.#protected, key, prot[key]);
}
if (this[ID]) {
Object.setPrototypeOf(this[ID], this[Base.classID]);
}
else {
this[ID] = this[Base.classID];
}
delete this[Base.classID];
}
if (target === Sibling) {
delete this[ID];
}
delete this._initProt;
}
constructor() {
super();
Object.defineProperty(this, "self", {value: this});
super.test();
this._initProt(new.target);
this.self.#protected.data += 2;
}
canISteal(other) {
try {
console.log(`Attempting theft.... ${other.#protected.data}`);
}
catch(e) {
console.log("Cannot steal! All is good.");
}
}
test() {
super.test();
super.test(Sibling);
console.log(`Sibling protected data: ${this.self.#protected.data}`);
}
}
Object.defineProperty(Sibling, "classID", {value: ID});
return Sibling;
})();
console.log("\nTesting instance of Base...");
(new Base).test();
console.log("\nTesting instance of Derived...");
(new Derived).test();
console.log("\nTesting instance of Sibling...");
(new Sibling).test();
console.log("\nTesting theft...");
(new Sibling).canISteal(new Derived);
This is fully functional code! If I were to hazard a guess about the original syntax, the following is one possibility.
class Base {
protected #data = 42;
test(_class = Base) {
console.log(`Instance of ${_class.name} has protected? ${this.hasOwnProperty(_class.classID)}`);
console.log(`Base protected data: ${this.#data}`);
}
};
class Derived extends Base {
constructor() {
super();
this._initProt(new.target);
++this.#protected.data;
}
test() {
super.test();
super.test(Derived);
console.log(`Derived protected data: ${this.#protected.data}`);
}
}
class Sibling extends Base {
#protected = {};
constructor() {
super();
super.test();
this.#protected.data += 2;
}
canISteal(other) {
try {
console.log(`Attempting theft.... ${other.#protected.data}`);
}
catch(e) {
console.log("Cannot steal! All is good.");
}
}
test() {
super.test();
super.test(Sibling);
console.log(`Sibling protected data: ${this.#protected.data}`);
}
}
console.log("\nTesting instance of Base...");
(new Base).test();
console.log("\nTesting instance of Derived...");
(new Derived).test();
console.log("\nTesting instance of Sibling...");
(new Sibling).test();
console.log("\nTesting theft...");
(new Sibling).canISteal(new Derived);
What I've got here is simple to construct and easy to understand, but by no means ergonomic in its expanded form. However, it does show that a protected implementation can be made using what we already have without having any of the leaky issues that seem to come hand-in-hand with so many of the other proposals that have been made in the past.
Ignoring issues 1 & 2 which may just be peeves of my own, is this enough to make protected seem like a viable possibility (@ljharb even if you don't think it belongs in the language)?