A pattern for implementing protected properties and methods in native JavaScript using ES2022 private fields and shared-state objects.
JavaScript doesn't natively support protected properties (properties accessible within a class hierarchy but not from outside). This library provides a sophisticated pattern to implement protected properties and methods using JavaScript's private fields (#), a subscription-based distribution system, and a shared-state object with prototype inheritance.
- True Protected Properties: Properties accessible within class hierarchies but not from outside
- Protected Shared-State Object: A single shared object with prototype chain for protected data and methods
- Protected Methods on Prototype: Methods defined on the protected prototype that can access protected state
- Pseudo-Protected Methods: Access-controlled methods that verify caller authenticity
- Cross-Instance Access: Naturally supports protected property access across instances of the same class
- Zero Dependencies: Pure JavaScript implementation
- Type Safe: Works seamlessly with TypeScript
- Lightweight: Minimal overhead with efficient subscription pattern
Incorporate the base-class pattern into your base class. Excerpted from protected-base.js:
// Base-class protected-properties-pattern essentials
class Base {
#guarded; // Base's private access to shared protected properties
#guardedSubs = new Set(); // Subscribers (setter functions)
// Base-class prototype for protected shared-state object
static protoProtected = {
logGuarded () {
// when called guarded.logGuarded (or this.#guarded.logGuarded):
// `this` will be the protected shared-state object
// `this.thys` will be the original object `this`
// Optional: verify main-object/protected-state-object association
if (this !== this.thys.#guarded) throw new Error('Unauthorized call');
console.log('Proto Base?', this.protoBase, 'Proto Sub?', this.protoSub);
console.log('Base #guarded:', this);
},
get protoBase () { return true; }
};
constructor () {
const guarded = this.#guarded = Object.create(this.constructor.protoProtected);
guarded.thys = this; // Back-reference to the instance
guarded.base = true; // Protected property
this._subGuarded(this.#guardedSubs); // Invite sub-class access
// Public props: this.prop
// Protected props: this.#guarded.prop (or guarded.prop)
// Private props: this.#prop
}
// Distribute protected-property access
_getGuarded () {
const guarded = this.#guarded, subs = this.#guardedSubs;
try {
for (const sub of subs) {
sub(guarded); // Attempt distribution to subscriber
subs.delete(sub); // Remove successfully-completed subscriptions
}
}
catch (_) { }
}
_subGuarded () { } // Base-class stub
}Incorporate the sub-class pattern into your sub-classes. Excerpted from protected-sub.js:
// Sub-class protected-properties-pattern essentials
class Sub extends Base {
#guarded; // Sub's private access to shared protected properties
// Sub-class prototype for protected shared-state object
static protoProtected = Object.setPrototypeOf({
logGuarded () {
console.log('Sub #guarded', this);
super.logGuarded(); // Call parent's protected method
},
get protoSub () { return true; }
}, super.protoProtected);
constructor () {
super();
// <-- Sub's this.#guarded no longer throws
this._getGuarded(); // Obtain protected-property access
// <-- Sub's this.#guarded is now populated and available for use
const guarded = this.#guarded;
guarded.sub = true; // Protected property
}
// Subscribe to #guarded protected properties
_subGuarded (subs) {
super._subGuarded(subs); // Must be first
subs.add((g) => this.#guarded ||= g); // Set this.#guarded once
}
}The shared-state object has a prototype chain that mirrors the class hierarchy. Each class defines its own protoProtected static property that extends the parent's:
// Base class
class Base {
static protoProtected = {
baseMethod () { console.log('Base method'); },
get protoBase () { return true; }
};
}
// Sub class extends the prototype
class Sub extends Base {
static protoProtected = Object.setPrototypeOf({
subMethod () {
super.baseMethod(); // Call parent's protected method
console.log('Sub method');
},
get protoSub () { return true; }
}, super.protoProtected);
}
// Conceptual structure (not strictly valid syntax)
const instance = new Sub();
const guarded = instance.#guarded;
guarded.baseMethod(); // Inherited from Base
guarded.subMethod(); // Defined in Sub
console.log(guarded.protoBase); // true (inherited)
console.log(guarded.protoSub); // true (own property)Cross-instance access is a natural consequence of how JavaScript private fields work. Since #guarded is class-private (not instance-private), methods within a class can access #guarded on other instances of the same (or more derived) classes:
class Sub extends Base {
// ...
// Compare to another node that is instanceof Sub (i.e. Sub or extends Sub)
// Note that this won't work with a new Base() instance because such an instance
// has a Base #guarded (inaccessible to Sub methods) but not a Sub #guarded.
compareWith (otherNode) {
const guarded = this.#guarded; // Sub-level #guarded of this instance
const otherGuarded = otherNode.#guarded; // Sub-level #guarded of otherNode
return guarded.value === otherGuarded.value;
}
}The pattern uses four key mechanisms:
-
Shared-State Object with Prototype Chain: The protected properties are stored in a single shared object created with
Object.create(this.constructor.protoProtected). This object has a prototype chain that mirrors the class hierarchy, allowing protected methods to be defined on the prototype. -
Private Fields (
#guarded): Each class in the hierarchy has its own private#guardedfield that references the same shared protected-state object. This ensures protected properties are accessible within the class hierarchy but not from outside. -
Subscription Pattern: Subclasses subscribe to receive the protected shared-state object through the
_subGuarded()method. The base class collects these subscriptions during construction. -
Distribution: The base class distributes the protected shared-state object to all subscribers via
_getGuarded(), which must be called in each subclass constructor aftersuper().
The shared-state object includes a thys property that references back to the original instance. This allows protected methods defined on the prototype to access the instance's private fields:
static protoProtected = {
logGuarded () {
// `this` is the shared-state object
// `this.thys` is the original instance
// Optional: verify main-object/protected-state-object association
if (this !== this.thys.#guarded) throw new Error('Unauthorized call');
console.log('Protected state:', this);
}
};class Example extends Base {
#guarded;
#privateField; // Private: only accessible in this class
static protoProtected = Object.setPrototypeOf({
// Protected method on prototype
protectedMethod () {
// Access protected properties via `this` (the shared-state object)
console.log('Protected value:', this.protectedField);
// Access instance via `this.thys`
console.log('Instance:', this.thys);
}
}, super.protoProtected);
constructor () {
super();
this._getGuarded();
const guarded = this.#guarded;
this.publicField = 'public'; // Public: accessible everywhere
guarded.protectedField = 'protected'; // Protected: accessible in hierarchy
this.#privateField = 'private'; // Private: only in this class
// Call protected method
guarded.protectedMethod();
}
}Protected methods can be defined on the protoProtected static property. These methods are accessible through the shared-state object and can access protected properties directly:
static protoProtected = {
// Protected method accessible via guarded.protectedMethod()
protectedMethod () {
// `this` is the shared-state object
console.log('Protected property:', this.protectedField);
// Access instance via `this.thys`
const instance = this.thys;
}
};
// Call from any method in the hierarchy
someMethod () {
this.#guarded.protectedMethod();
}Pseudo-protected methods are publicly-visible methods that require the caller to pass the shared-state object to verify authenticity. This pattern is useful when you need a method to be callable from outside but want to restrict access:
// Pseudo-protected method (publicly visible but access-controlled)
guardedMethod (guarded) {
if (guarded !== this.#guarded) throw new Error('Unauthorized method call');
// Caller is confirmed to be in the class hierarchy for this instance
}
// A method at any class level can call a pseudo-protected method on its own instance
// (the #guarded of each class refers to the same shared object)
callGuardedMethod () {
this.guardedMethod(this.#guarded);
}
// A method can also call a pseudo-protected method on another instance
// if the other instance is instanceof the calling method's class
// (A method in a more-derived sub-class cannot protected-call a less-derived instance)
callOtherGuardedMethod (other) {
try {
const otherGuarded = other.#guarded; // Throws if other is incompatible
other.guardedMethod(otherGuarded);
} catch (_err) {
// TypeError thrown if other is incompatible
}
}Works in all modern browsers and Deno / Node.js / etc. environments that support:
- ES6 Classes
- Private fields (
#)
This content is placed in the public domain by the author.
- Blog Post: Implementing JavaScript Protected Properties
- Author: Brian Katzung briank@kappacs.com
This is a pattern demonstration. Feel free to adapt it to your needs or suggest improvements via issues and pull requests.