In many object-oriented languages, the concept of protected members allows properties and methods to be accessible within the class they are defined, as well as by subclasses. JavaScript, however, does not natively support protected members. This article introduces a novel approach to simulate protected access in JavaScript classes using symbols and private fields.
JavaScript provides private fields, but they are not accessible to any derived classes. This limitation means that developers cannot use private fields to directly implement protected members, which are a staple in many other object-oriented languages.
The solution involves using symbols as keys for protected properties and methods, ensuring that only the class and its subclasses have access to them. This is achieved by:
- Generating a unique symbol in the subclass.
- Passing this symbol to the superclass constructor.
- Using the symbol as a key to assign
protectedmembers to the subclass instance.
Below is the implementation detail of our approach.
class SuperProtected {
#_api = null;
#_protectedMember3 = 0;
constructor(subclassSymbol) {
this[subclassSymbol] = this.#getProtectedMembers();
}
#getProtectedMembers() {
if (this.#_api == null) {
const api = {};
Object.defineProperties(api, {
// Define protected methods
protectedMember: {
value: this.#_protectedMember.bind(this),
enumerable: true
},
protectedMember2: {
value: this.#_protectedMember2.bind(this),
enumerable: true
},
// Define a protected property with a getter and setter
protectedMember3: {
get: () => this.#_protectedMember3,
set: (val) => { this.#_protectedMember3 = val; },
enumerable: true
}
});
this.#_api = api;
}
return this.#_api;
}
#_protectedMember() {
console.log('I am protected 1');
}
#_protectedMember2() {
console.log('I am protected 2');
}
}In the SuperProtected class, we define a method #getProtectedMembers that creates an object api with properties corresponding to the protected members. We use Object.defineProperties to set up our protected API.
class SubWithAccess extends SuperProtected {
#$; // A private field to store the protected API
constructor() {
const $ = Symbol(`[[protected]]`);
super($);
this.#$ = this[$]; // Assign the protected API to the private field
this[$] = null;
}
demonstrate() {
// Demonstrate the use of protected members
this.#$.protectedMember2();
this.#$.protectedMember3 = 555;
this.#$.protectedMember();
console.log('Value of protected member 3', this.#$.protectedMember3);
}
}In the subclass SubWithAccess, we define a private field #$ and initialize it with the protected API received from the superclass.
const subInstance = new SubWithAccess();
subInstance.demonstrate();When we create an instance of SubWithAccess and call demonstrate, we access the protected members through the private symbol-keyed property.
- Encapsulation: The
protectedmembers are not accessible from outside the class hierarchy. - Clarity: The use of symbols and a consistent API makes the intention behind
protectedmembers clear. - Flexibility: This pattern can be extended to multiple levels of inheritance.
- It's a hack: It's not an officially supported way of doing things, while possible it's stretching the syntax beyond what's intended.
- It's wordy: I don't know about you but my fingers get tired typing
$#.and#_ugh. - There could be better ways: It's possible someone has come up with a cleaner approach for those who want strongly enforced encapsulation.
- The syntax is kind of ugly: Too many symbols I think.
- It's a lot of set up: Granted you could only have to set it up in your super class, but there's a lot of boilerplate and plumbing every time you want to define a protected member.
You can avoid the need for $ or symbols at all by passing an object to the superclass constructor, dependency injection style. This removes the need for any properties on the subclass instance, and prevents access to the protected API via Object.getOwnPropertySymbols, for example. If you wanted to use symbols anyway, you could create the symbol property using defineProperty with enumerable: false, which would prevent it appearing in the list returned by getOwnPropertySymbols.
Improved pattern:
// superclass
constructor(api) {
this.#imprintProtectdMembers(api);
}
// ... change api to parameter in getProtectedMembers to create imprintProtectedMembers
//subclass
#$ = null;
constructor() {
const api = {};
super(api);
this.#$ = api;
}JavaScript's flexibility allows for creative solutions to common problems. By combining private fields, symbols, and property descriptors, we can simulate protected members in a way that respects the principles of object-oriented design.
This technique provides a strong encapsulation while giving enough flexibility for subclasses to utilize and manage their inherited properties and methods effectively.
Feel free to experiment with this approach and see how it can fit into your JavaScript projects!
I took a slightly different approach. Instead of pushing a symbol or object up from sub-classes, I invite sub-classes to subscribe to the protected state object from the base constructor. Each sub-class subscribes by adding an idempotent setter function to a
Setpassed by the constructor. The set is never exposed externally. Aftersuper()returns, the sub-class requests the base class to pass the protected state using the accumulated setter functions, synchronizing a private reference to the shared state in each sub-class.Bound protected methods was one of the approaches I considered, but reviewing them again here gave me a great idea: include the original
thisasthysin the protected state (added by the base constructor) so that protected methods can access it viathis.thys. Voila! Prototype-based protected methods with no more binding required!Moving from "pseudo-protected" methods on the main class prototype-chain (public functions using the protected state object as a shared private key), I also implemented a parallel prototype chain for the protected state object. Voila! Efficient, sub-class-able/
super-able protected methods!My repo is https://github.com/bkatzung/protected-js if you're interested in taking a look.