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 () {}
a1anda2will have access to each other’s protected properties- Class
Amethods ofb1andb2will have access toa1,a2,b1, andb2(allinstanceof A) protected properties - Class
Bmethods ofb1andb2will have access tob1andb2(instanceof B) but not toa1ora2protected properties - Class
Amethods ofc1andc2will have access toa1,a2,b1,b2,c1, andc2(allinstanceof A) protected properties - Class
Bmethods ofc1andc2will have access tob1,b2,c1, andc2(allinstanceof B), but nota1ora2protected properties - Class
Cmethods ofc1andc2will have access toc1andc2(instanceof C) but not toa1,a2,b1, orb2protected properties d1,d2, andfwill not have access toa1,a2,b1,b2,c1, orc2protected 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>
export class A { // Must be in the same file as guardedMap
constructor () {
const guarded = { /* initial protected properties here */ };
guardedMap.set(this, guarded);
// guarded.property
}
baseMethod () {
const guarded = guardedMap.get(this);
}
}
export class B extends A { // Must be in the same file as guardedMap
constructor () {
super();
const guarded = guardedMap.get(this);
// May add additional protected properties
}
subMethod () {
const guarded = guardedMap.get(this);
}
}
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 **
export class A {
#guarded; // Class-A-visible view of shared protected-properties object
constructor () {
const guarded = this.#guarded = { /* initial protected properties here */ };
this._setGuarded(guarded); // Try to "push" guarded across the class hierarchy
}
_setGuarded () { /**/ } // Base-class stub
}
export class B extends A {
#guarded; // Class-B-visible view of shared protected-properties object
constructor () {
super();
// Ideally, this.#guarded should be available here
}
// Set local #guarded from value passed by base-class constructor
_setGuarded (guarded) {
// #guarded is not yet "attached" at the time _setGuarded is called
this.#guarded = guarded; // FAILS!
}
}
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. Once 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)
// ---------- Base-class file ----------
export class A {
#guarded; // Class-A-visible view of shared protected-properties object
#guardedSubs = new Set(); // Protected-properties subscription setter functions
static protoProtected = {
protectedMethod () {
// Optional - verify this protected-properties object is official:
if (this !== this.thys.#guarded) throw new Error('Unauthorized');
// this: the protected-properties object
// this.thys: the original object
}
};
// this.#guarded.protectedMethod(...)
constructor () {
const guarded = this.#guarded = Object.create(this.constructor.protoProtected);
guarded.thys = this; // Allows unbound prototype methods to find "this"
this._subGuarded(this.#guardedSubs); // Invite sub-classes to subscribe to access
}
// Distribute this.#guarded; called by sub-class constructors
_getGuarded () {
const guarded = this.#guarded, subs = this.#guardedSubs;
try {
for (const sub of subs) {
sub(guarded); // Try to distribute guarded; throws if not yet attached
subs.delete(sub); // Remove successfully-completed subscriptions
}
} catch (_) { /**/ }
}
_subGuarded () { /**/ } // Base-class stub
gatedMethod (guarded) {
if (guarded !== this.#guarded) throw new Error('Unauthorized');
// ...
}
// this.gatedMethod(this.#guarded, ...)
}
// ---------- Sub-class file ----------
import { A } from '...';
export class B extends A {
#guarded; // Class-B-visible view of shared protected-properties object
static protoProtected = Object.setPrototypeOf({
protectedMethod () {
if (this !== this.thys.#guarded) throw new Error('Unauthorized');
super.protectedMethod();
// ...
}
}, A.protoProtected);
constructor () {
super();
this._getGuarded();
// <-- this.#guarded is synchronized and ready here
}
_subGuarded (subs) { // Subscribe to protected-properties access
super._subGuarded(subs);
// subscription setter function sets local #guarded once
// after attachment
subs.add((g) => this.#guarded ||= g);
}
}
Protected Properties
Each class (base and sub-class) gets a private #guarded, which, through synchronization, will be made to point to the same shared (per-instance), protected-properties object.
During construction, the base class invites sub-classes to subscribe to receive access to 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 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() after it calls super() in order 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 that was 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.
Protected Methods
The shared protected-properties object approach lends itself to two protected-methods approaches: protected methods associated with protected-properties object itself, and pseudo-protected methods (publicly-visible methods on the main object prototype chain that use the protected-properties object as an access token). I’ll cover the pseudo-protected-methods approach in the next section.
We can create protected methods by adding them “directly” (or via object prototype) to the shared protected-properties object. These can then be called as e.g. this.#guarded.protectedMethod(...). Note that the “this” within protectedMethod will, by default, be the protected-properties object, not the original object.
One possible way to grant access to the original this is to add each protected method as a bound function, but this approach requires adding a custom binding per-object-and-method, which is not very efficient.
By adding a “thys” protected-property referring to the original object as a standard part of the pattern, traditional unbound methods can be used instead (they just need to reference this.thys instead of this to refer to the main object).
By creating the protected-properties object using a chained-prototype approach, it is possible to have sub-classing of methods and super.method calls, just as for the main object class hierarchy (prototype chain).
The pattern, as shown, builds the protected-properties object prototype chain by making the prototype for each class publicly visible. It could also be built “privately” (without public exposure of the prototypes) with some additional steps performed at construction (not covered in the scope of this post).
If a protected method wants to confirm that this (the protected-properties object) and this.thys (the original object) are a properly matched pair, it can do so by testing this === this.thys.#guarded.
Pseudo-Protected Methods
Here, a “pseudo-protected method” is a publicly-visible method (so not truly protected in the traditional sense) on the main-object prototype-chain that provides gated access to its functionality via a shared secret. In this context, the shared secret can be this.#guarded, since it’s known to methods within the base-class and each sub-class in the inheritance hierarchy.
These methods should throw an exception or return some innocuous value if not called from within the class hierarchy (i.e. not passed this.#guarded).
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.
Resources
This code is also available on GitHub at https://github.com/bkatzung/protected-js.
Related
Securely Implementing Trusted, “Friend”-Like Classes In JavaScript
