Skip to content

Instantly share code, notes, and snippets.

@o0101
Last active November 10, 2023 09:37
Show Gist options
  • Save o0101/636a40f9e47967cf14b0d4b5ebd68e72 to your computer and use it in GitHub Desktop.
Save o0101/636a40f9e47967cf14b0d4b5ebd68e72 to your computer and use it in GitHub Desktop.
Implementing Protected Members in JavaScript by Hacking Symbols and Private Fields

Implementing Protected Members in JavaScript Classes

Introduction

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.

The Challenge

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.

One Solution

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:

  1. Generating a unique symbol in the subclass.
  2. Passing this symbol to the superclass constructor.
  3. Using the symbol as a key to assign protected members to the subclass instance.

Code Walkthrough

Below is the implementation detail of our approach.

The Superclass

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.

The Subclass

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.

Demonstration

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.

Benefits of This Approach

  • 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.

Problems with This Approach

  • 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.

Improvements

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;
}

Conclusion

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!

// code demo
class SuperProtected {
#_api = null;
#_protectedMember3 = 0;
constructor(subclassSymbol) {
this[subclassSymbol] = this.#getProtectedMembers();
console.log(subclassSymbol, this[subclassSymbol]);
}
#getMember3() {
console.log('getting 3');
return this.#_protectedMember3;
}
#setMember3(val) {
console.log('setting 3', val);
this.#_protectedMember3 = val;
console.log(this.#_protectedMember3);
}
#getProtectedMembers() {
if ( this.#_api == null ) {
const api = {};
// enumerable is optional and here one purpose is to show using log
// use arrows for getters and setters or remember to rebind this as in methods
Object.defineProperties(api, {
protectedMember: { value: this.#_protectedMember.bind(this), enumerable: true },
protectedMember2: { value: this.#_protectedMember2.bind(this), enumerable: true },
protectedMember3: {
get: () => this.#_protectedMember3,
set: (val) => {
this.#_protectedMember3 = val;
},
enumerable: true
}
});
this.#_api = api;
}
return this.#_api;
}
#_protectedMember() {
console.log('I am');
}
#_protectedMember2() {
console.log('I am 2');
}
}
class SubWithAccess extends SuperProtected {
#$; // access ancestor procted api under this symbol
constructor() {
const $ = Symbol(`[[protected]]`);
super($);
// assign the private field to be the value of the slot keyed by its symbol value
this.#$ = this[$];
}
demonstrate() {
this.#$.protectedMember2();
this.#$.protectedMember3 = 555;
this.#$.protectedMember();
console.log('Value of protected member', this.#$.protectedMember3);
}
}
const subInstance = new SubWithAccess();
subInstance.demonstrate();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment