Background
It’s often desirable to be able to control the visibility of an object’s properties. Sometimes it’s convenient for an object’s properties to be publicly accessible, sometimes base-classes and derived-classes need to share access, and sometimes you don’t want to allow any access outside of the defining class.
Many languages, including the TypeScript derivative of JavaScript, include access control keywords such as public, protected, and private for this. The options available natively within JavaScript are more limited (and TypeScript’s protections cannot protect against non-TypeScript-generated JavaScript).
JavaScript supports public object properties (the default), an unenforced convention of using an underscore (_) prefix before protected/private properties, and, since ECMAScript 2022, “private elements” (fields, methods, properties) via hash (#) name-prefixes.
Private elements are accessible by class. Any code within the defining class can access the private elements of any instance of that class. Private-element names must be unique across all of the private elements within a class, but are available for reuse in other classes. Code does not have access to the private elements of other classes within the same class hierarchy.
Intended Scope
The goal of this implementation is to make data accessible to all of the methods within an instance’s class hierarchy, and inaccessible (except via class-provided interfaces) to all other code.
class A {}
const a1 = new A(), a2 = new A();
class B extends A {}
const b1 = new B(), b2 = new B();
class C extends B {}
const c1 = new C(), c2 = new C();
class D {}
const d1 = new D();
function f () {}
a1 and a2 will have access to each other’s protected properties
- Class
A methods of b1 and b2 will have access to a1, a2, b1, and b2 (all instanceof A) protected properties
- Class
B methods of b1 and b2 will have access to b1 and b2 (instanceof B) but not to a1 or a2 protected properties
- Class
A methods of c1 and c2 will have access to a1, a2, b1, b2, c1, and c2 (all instanceof A) protected properties
- Class
B methods of c1 and c2 will have access to a1, a2, b1, and b2 (all instanceof B), but not c1 or c2 protected properties
- Class
C methods of c1 and c2 will have access to c1 and c2 (instanceof C) but not to a1, a2, b1, or b2 protected properties
d1, d2, and f will not have access to a1, a2, b1, b2, c1, or c2 protected data
Notice that you cannot gain additional access to an existing instance of an existing type by creating a new sub-class with additional methods (the additional methods only have access to its own instances or instances of its own sub-classes).
Historical Approach: Lexical Scoping
One historical approach for storing protected (and before ES2022, private) properties is through the use of scoped storage, like this (note that I am using “guarded” instead of “protected” to avoid TypeScript (and possibly future JavaScript) keyword confusion):
const guardedMap = new WeakMap(); // <instance, protectedProperties>
class A {
#guarded; // Class A private cached lookup result
constructor () {
const guarded = this.#guarded = { /* protected properties here */ };
guardedMap.set(this, guarded);
// Public properties: this.prop
// Protected properties: this.#guarded.prop (or guarded.prop)
// Private properties: this.#prop
}
}
class B extends A { // In the same source file
#guarded; // Class B private cached lookup result
constructor () {
super();
const guarded = this.#guarded = guardedMap.get(this);
// The same protected properties are now visible in class B methods
}
}
However, requiring all of the related classes for a hierarchy to exist within a single file is often impractical for a number of reasons (file size, different authors, different development timeframes, etc).
You can include accessor methods in the base class to allow sub-classes in other files to gain access, but then there’s nothing to prevent code outside of the class hierarchy from using the accessors to gain access too.
Fortunately, with just a bit more effort, we can use a more tightly-controlled approach.
Goals For A Better Implementation
- Related classes within a class hierarchy must have shared access to protected properties
- Related classes must not need to reside within the same source file (i.e. support multiple lexical scopes)
- Access from outside the class hierarchy should be prevented at the language level
- Protected properties should be available for use as soon as possible during object construction
- Avoid TypeScript (and maybe future JavaScript?) “
protected” keyword confusion (I’ll continue to use “guarded” instead)
- Some form of protected methods (methods that can only be invoked from within the class hierarchy)
- Note: This implementation does not include generating nested protected scopes (a single protected scope will be shared across the class hierarchy)
“Threaded-Access” Strategy
Let’s use a different solution (one that doesn’t risk public access) by “threading” access between classes in the hierarchy via a common method defined in each class level, with each method invoking the next using “super“. Conceptually, the approach looks something like this:
// ** CONCEPT ONLY - CODE WILL NOT WORK **
class A {
#guarded; // Class A private access to shared protected properties
constructor () {
const guarded = { /* protected properties here */ };
this._setGuarded(guarded); // Initiate #guarded threading at target class
// #guarded is now threaded; constructor returns to sub-class constructors
}
_setGuarded (guarded) { // Final stop for #guarded threading
if (!this.#guarded) { // Only set once (during construction)
this.#guarded = guarded;
}
}
}
class B extends A { // Can be in a different file
#guarded; // Class B private access to same shared protected properties
constructor () {
super();
// this.#guarded is now threaded and available for use
}
_setGuarded (guarded) {
if (!this.#guarded) {
this.#guarded = guarded;
super._setGuarded(guarded); // Thread access up through the class hierarchy
}
}
}
The code above won’t work as-is, because private elements aren’t associated with the object until after the super call has completed. In this specific example, the B class this.#guarded does not yet exist at the time the B class _setGuarded is called from the A constructor, because the A constructor has not yet returned to the B constructor.
We can get around that problem by using a subscription-based, “pull model” that operates strictly within the class hierarchy. Working protected state also provides a way to offer pseudo-protected methods that can only be invoked from within the class hierarchy. The details are covered in the following section.
Cross-File, “Threaded” JavaScript Protected Properties
(Final Implementation)
// ** File 1 **
export class A { // Base class
#guarded; // Class A private access to shared protected properties
#guardedSubs = new Set(); // Protected-property subscriptions (setter functions)
constructor () {
const guarded = this.#guarded = { /* protected properties */ };
this._subGuarded(this.#guardedSubs); // Invite subscribers
// Public props: this.prop
// Protected props: this.#guarded.prop (or guarded.prop)
// Private props: this.#prop
}
// Distribute protected property access to ready subscribers
// (base instance method)
_getGuarded () {
const guarded = this.#guarded, subs = this.#guardedSubs;
try {
for (const sub of subs) {
sub(guarded); // Attempt guarded distribution to subscriber
subs.delete(sub); // Remove successfully-completed subscriptions
}
}
catch (_) { }
}
// Optional base-class stub for sub-class interface consistency
_subGuarded () { }
method () { // Example consumer
const guarded = this.#guarded;
// Public props: this.prop
// Protected props: this.#guarded.prop (or guarded.prop)
// Private props: this.#prop
}
// A pseudo-protected (publicly visible, but access-controlled) method
// Callers must supply their private #guarded to authenticate
guardedMethod (guarded) {
if (guarded !== this.#guarded) throw new Error('Unauthorized method call');
// Caller is now confirmed to be in the class hierarchy for this instance
}
}
// ** File 2 **
// import { A } from '...';
class B extends A {
#guarded; // Class B private access to same shared protected properties
constructor () {
super();
this._getGuarded(); // Obtain protected property access
// this.#guarded is now populated and available for use here
const guarded = this.#guarded;
}
_subGuarded (subs) { // Subscribe to protected properties
super._subGuarded(subs); // Optional if super-class is the base
subs.add((g) => this.#guarded ||= g); // Set this.#guarded once
}
// A method within any class in the hierarchy can call a
// pseudo-protected method on its own instance (all classes
// in the hierarchy see the same #guarded)
callGuardedMethod () {
const guarded = this.#guarded;
this.guardedMethod(guarded);
}
// A method can also call a pseudo-protected method on another
// instance if the called instance is instanceof the calling
// method's class (i.e. at least as derived in the same hierarchy)
callOtherGuardedMethod (other) {
try {
other.guardedMethod(other.#guarded);
} catch (_err) {
// TypeError thrown if there is no <this class>-level #guarded
}
}
}
Explanation: Protected Properties
During construction, the base class invites sub-classes to subscribe to receive the protected properties (base #guarded object). Only classes in the hierarchy receive the invitation (it’s never externally accessible).
Classes wanting protected-property access respond to the invitation (they subscribe) by calling the super-method and then adding a setter function (which accepts a protected-properties object and sets their private #guarded) to the subscription-set (subs) passed to _subGuarded.
Important: The super-method (super._subGuarded(subs)) must be called before adding the setter function to the subscription-set so that setter functions get added in least-derived-class-to-most-derived-class (i.e. top-to-bottom) order.
Each sub-class constructor calls this._getGuarded() to set its private this.#guarded to the shared protected-protected properties object. This works by attempting to execute each setter function in the subscription-set collected by the base-class constructor. A setter will complete (and be removed from the subscription-set) only if the associated class has returned from its constructor’s super() call.
In any class in which the super() call has not yet returned, attempting to set its this.#guarded in its setter function will throw an exception (with the side effect of leaving the setter function in the subscription-set to be attempted again in a subsequent call).
The net effect is that the private this.#guarded gets set, class-by-class, right after each super() call completes.
The setter-function subscriptions are idempotent. It’s possible to recreate the subscription-set by calling _subGuarded post-construction and run all the setter functions again (attempting to set a different protected-properties object), but as the setters have already set each this.#guarded during construction, running them again has no effect.
Explanation: Pseudo-Protected Methods
Once we have successfully created protected state (visible only to code within the class hierarchy), we can use that as a form of authentication token for “pseudo-protected” methods.
These are publicly visible methods (so not truly protected in the traditional sense) that throw an exception (or return a different, presumably innocuous, value) if not called from within the class hierarchy.
Since the calling code and the called code both independently know the non-public this.#guarded value (object) from the construction phase, the caller simply needs to pass it as a parameter. The called code compares the passed value to it’s own this.#guarded, confirming the caller is within the class hierarchy if they match.
Note that an instance cannot cross-instance call a pseudo-protected method on a less-derived instance than the calling method’s class. Given instances:
const a = new A(), b = new B(); // where B extends A
a can call protected methods on b (and vice-versa for A-class methods of b) because a and b both have an A-level #guarded. B-class methods of b, however, cannot call protected methods on a because there is no B-level #guarded for a.
If you need “real” protected methods, you can add bound methods/functions to the protected state so that they’re only visible within the class hierarchy, but these would need to be bound and added per instance.
Resources
This code is also available on GitHub at https://github.com/bkatzung/protected-js.
Related
JavaScript Object Property Encapsulation: Beyond Public, Protected, And Private