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!