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
protected
members 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
protected
members are not accessible from outside the class hierarchy. - Clarity: The use of symbols and a consistent API makes the intention behind
protected
members 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!