Private Class Members in ES6: A Complete Guide

This guide tells you how to write utility functions to facilitate private members, including properties and methods, for multiple ES6 classes with minimal code duplication, and some ideas on how we might guard against derived classes’ accessing base classes’ private members.

The final code is available as two GitHub Gists: this Gist for those who don’t care about the inheritances, and this Gist for those who do (but please definitely check the Inheritance section of this guide for caveats).

Preface

A lot of materials exist online teaching us how we can use various ES6 constructs & patterns to implement access restrictions to object properties. One common technique involves using closures and ES6 Weakmaps, like on MDN or this Stackoverflow answer. Weakmaps and the associated internal functions are great for native (or rather, syntactic sugar-coated) ES6 classes, but what I have found online is that all the code examples out there don’t tell us how we scale them. If we have multiple ES6 classes living in their own separate encompassing closures, do we want to write an explicit new WeakMap() instantiation and the internal function every time we write a class? How can we implement private instance methods with the WeakMap patterns?

Multiple Classes

Let’s start from MDN’s namespaced implementation. Suppose we have two ES6 classes living in separate encompassing closures (and export them using non-ES6-module means):
(sorry for my syntax highlighter not knowing ES6 syntax)

;(function (exports){ // <- encompassing closure 

let privatePropertyMap = new WeakMap();

function _(instance) {
    if (!privatePropertyMap.has(instance)) {
        privatePropertyMap.set(instance, {});
    }
    return privatePropertyMap.get(instance);
}

class Class1 {
  constructor(property) {
    _(this)._privProperty = property;
  }

  pubMethod() {
    console.log(this.constructor.name, _(this)._privProperty);
  }
}

exports.Class1 = Class1;

})(window);


;(function (exports){

let privatePropertyMap = new WeakMap();

function _(instance) {
    if (!privatePropertyMap.has(instance)) {
        privatePropertyMap.set(instance, {});
    }
    return privatePropertyMap.get(instance);
}

class Class2 {
  constructor(property) {
    _(this)._privProperty = `I got a ${property}`;
  }

  pubMethod() {
    console.log(this.constructor.name, _(this)._privProperty);
  }
}

exports.Class2 = Class2;

})(window);

new Class1('some').pubMethod();
new Class2('parameter').pubMethod();

See all those duplicated codes? Yikes! Now, a naīve approach to refactor the duplicated code into a separate function will fail miserably --- if we "export" the _() internal function, any client side code can retrieve an object (that the client side instantiates)'s private properties by feeding the it into the internal function:

;(function (exports){

let privatePropertyMap = new WeakMap();

function _(instance) {
    if (!privatePropertyMap.has(instance)) {
        privatePropertyMap.set(instance, {});
    }
    return privatePropertyMap.get(instance);
}

exports._ = _;

})(window);


;(function (exports){

class Class1 {
  constructor(property) {
    _(this)._privProperty = property;
  }

  pubMethod() {
    console.log(this.constructor.name, _(this)._privProperty);
  }
}

exports.Class1 = Class1;

})(window);


;(function (exports){

class Class2 {
  constructor(property) {
    _(this)._privProperty = `I got a ${property}`;
  }

  pubMethod() {
    console.log(this.constructor.name, _(this)._privProperty);
  }
}

exports.Class2 = Class2;

})(window);

let obj1 = new Class1('some');
obj1.pubMethod();
let obj2 = new Class2('parameter');
obj2.pubMethod();

// will successfully output the private property
console.log(_(obj1)._privProperty);
console.log(_(obj2)._privProperty);

No good. However, this can be mitigated if we use two-level mapping, and closuring the internal function like this:

;(function (exports){

// uniqueKey -> WeakMap (instance -> members)
let privatePropertyMap = new WeakMap();

function createInternalFunction() {
  let uniqueKey = []; // we can also use const here

  if (!privatePropertyMap.has(uniqueKey)) {
    privatePropertyMap.set(uniqueKey, new WeakMap());
  }

  return function(instance) {
    if (!privatePropertyMap.get(uniqueKey).has(instance)) {
      privatePropertyMap.get(uniqueKey).set(instance, {});
    }

    return privatePropertyMap.get(uniqueKey).get(instance);   
  }
}

exports.createInternalFunction = createInternalFunction;

})(window);


;(function (exports){

let _ = createInternalFunction();

class Class1 {
  constructor(property) {
    _(this)._privProperty = property;
  }

  pubMethod() {
    console.log(this.constructor.name, _(this)._privProperty);
  }
}

exports.Class1 = Class1;

})(window);


;(function (exports){

let _ = createInternalFunction();

class Class2 {
  constructor(property) {
    _(this)._privProperty = `I got a ${property}`;
  }

  pubMethod() {
    console.log(this.constructor.name, _(this)._privProperty);
  }
}

exports.Class2 = Class2;

})(window);

let obj1 = new Class1('some');
obj1.pubMethod();
let obj2 = new Class2('parameter');
obj2.pubMethod();

let _ = createInternalFunction();

// Outputs undefined as we aren't able to access the private properties anymore 😉
console.log(_(obj1)._privProperty);
console.log(_(obj2)._privProperty);

Essentially, we generate class-specific internal functions within the closure where the class definition is encompassed; "specific" lies in the uniqueKey array instance (uniquely instantiated every time we call createInternalFunction). There is literally no way to access the private properties anymore. Note that the first level of the mapping can still be a WeakMap instead of a strong Map since a reference to the unique array is kept in createInternalFunction().

Private Methods

privateMethods Object Approach

Nobody out there illustrates how to do private methods. Well, to be honest, there isn't a need: an arbitrary function (either defined using function() or a class method) inside the closure suits the job well as long as we bind() the correct contextual this when calling the function:

// createInternalFunction omitted...

;(function (exports){

let _ = createInternalFunction();

function _privMethod() {
  console.log(this.constructor.name, 'In private', _(this)._privProperty)
}

class Class1 {
  constructor(property) {
    _(this)._privProperty = property;
  }

  pubMethod() {
    _privMethod.bind(this)();
  }
}

exports.Class1 = Class1;

})(window);

let obj1 = new Class1('some');
obj1.pubMethod();

But, can this binding be made automatic? Can we also invoke the function in a more natural syntax, like _(this)._privateMethod()? Yes! we can do that in createInternalFunction(), by binding the functions in generatePrivateMemberObject():

;(function (exports){

// classPrivateMethods -> WeakMap (instance -> members)
let privateMemberMap = new WeakMap();

function createInternalFunction(classPrivateMethods) {
  if (!privateMemberMap.has(classPrivateMethods)) {
    privateMemberMap.set(classPrivateMethods, new WeakMap());
  }

  function generatePrivateMemberObject(instance) {
    let obj = {};

    Object.entries(classPrivateMethods)
      .forEach(([name, func]) => {
        obj[name] = func.bind(instance);
      });

    return obj;  
  }

  return function(instance) {
    if (!privateMemberMap.get(classPrivateMethods).has(instance)) {
      privateMemberMap.get(classPrivateMethods).set(
        instance,
        generatePrivateMemberObject(instance)
      );
    }

    return privateMemberMap.get(classPrivateMethods).get(instance);   
  }
}

exports.createInternalFunction = createInternalFunction;

})(window);


;(function (exports){

const privateMethods = Object.seal({
  _privMethod1() {
    console.log(this.constructor.name, "At private method 1 on obj", _(this)._privProperty);
  },

  _privMethod2() {
    console.log(this.constructor.name, "At private method 2 on obj", _(this)._privProperty);
    _(this)._privMethod1();
  },

  _privMethod3() {
    console.log(this.constructor.name, "At private method 3 on obj", _(this)._privProperty);
    this.pubMethod4();
  },
});

let _ = createInternalFunction(privateMethods);

class Class1 {
  constructor(property) {
    _(this)._privProperty = property;
  }

  pubMethod1() {
    console.log(this.constructor.name, "At public method 1 on obj", _(this)._privProperty);
    _(this)._privMethod1();
  }

  pubMethod2() {
    console.log(this.constructor.name, "At public method 2 on obj", _(this)._privProperty);
    _(this)._privMethod2();
  }

  pubMethod3() {
    console.log(this.constructor.name, "At public method 3 on obj", _(this)._privProperty);
    _(this)._privMethod3();
  }

  pubMethod4() {
    console.log(this.constructor.name, "At public method 4 on obj", _(this)._privProperty);
  }  
}

exports.Class1 = Class1;

})(window);

let obj1 = new Class1('some');
obj1.pubMethod1();
obj1.pubMethod2();
obj1.pubMethod3();
obj1.pubMethod4();

let obj11 = new Class1('another');
obj11.pubMethod1();
obj11.pubMethod2();
obj11.pubMethod3();
obj11.pubMethod4();

Essentially, when we first generate the instance-specific private-member object, we bind the private methods with the instance, and insert them into the object.

The privateMethods object should never be used directly, or exported to outside (a better way to ensure this is to remove the use of privateMethods variable and directly use the object literal as createInternalFunction()'s parameter, at the cost of reduced readability.)

Note we spare the use of the empty array as unique keys here, since privateMethods is class-specific, won't (or shouldn't) be exposed, and can be conveniently used in lieu of the key.

Oh by the way, we don't need to prefix the private members with underscore nor do we need to name the internal function as exactly one underscore. But I find that this naming convention makes private members readily visible. Also, prefixing private methods with an underscore comes with one twist in implementation, a trade-off in software engineering design.

_-Prefixed Method Names Approach

Prefixing private methods with an underscore enables one extra implementation variant: As we can infer what methods are private from their names, we don't really need a separate privateMethods object; they can stay inside the class definition. It's simpler, but we are actually making a software engineering design trade-off here by codifying and coupling underscore prefixes and private methods. As with all trade-offs, exercise them with extra caution!

;(function (exports){

// uniqueKey -> WeakMap (instance -> members)
let privateMemberMap = new WeakMap();

function createInternalFunction(classType) {
  let uniqueKey = [];
  let classPrivateMethods = {};

  Object.getOwnPropertyNames(classType.prototype)
    .filter(name => name.startsWith('_') &&
            classType.prototype[name] instanceof Function)
    .forEach(name => {
      classPrivateMethods[name] = classType.prototype[name];
      delete classType.prototype[name];
    });

  if (!privateMemberMap.has(uniqueKey)) {
    privateMemberMap.set(uniqueKey, new WeakMap());
  }

  function generatePrivateMemberObject(instance) {
    let obj = {};

    Object.entries(classPrivateMethods)
      .forEach(([name, func]) => {
        obj[name] = func.bind(instance);
      });

    return obj;  
  }

  return function(instance) {
    if (!privateMemberMap.get(uniqueKey).has(instance)) {
      privateMemberMap.get(uniqueKey).set(
        instance,
        generatePrivateMemberObject(instance)
      );
    }

    return privateMemberMap.get(uniqueKey).get(instance);   
  }
}

exports.createInternalFunction = createInternalFunction;

})(window);


;(function (exports){

class Class1 {
  constructor(property) {
    _(this)._privProperty = property;
  }

  pubMethod1() {
    console.log(this.constructor.name, "At public method 1 on obj", _(this)._privProperty);
    _(this)._privMethod1();
  }

  pubMethod2() {
    console.log(this.constructor.name, "At public method 2 on obj", _(this)._privProperty);
    _(this)._privMethod2();
  }

  pubMethod3() {
    console.log(this.constructor.name, "At public method 3 on obj", _(this)._privProperty);
    _(this)._privMethod3();
  }

  pubMethod4() {
    console.log(this.constructor.name, "At public method 4 on obj", _(this)._privProperty);
  }  

  _privMethod1() {
    console.log(this.constructor.name, "At private method 1 on obj", _(this)._privProperty);
  }

  _privMethod2() {
    console.log(this.constructor.name, "At private method 2 on obj", _(this)._privProperty);
    _(this)._privMethod1();
  }

  _privMethod3() {
    console.log(this.constructor.name, "At private method 3 on obj", _(this)._privProperty);
    this.pubMethod4();
  }
}

let _ = createInternalFunction(Class1);

exports.Class1 = Class1;

})(window);

Obviously, we now have to reintroduce the unique key.

Inheritance

Inheritance makes the constructs in this guide counter-intuitive. First of all, there is still no protected mechanism if our base and derived classes live in different encompassing closures --- there is just no way to export the base class's protected methods to the derived class without also letting any third party know them. In this sense, private methods remain purely private in OOP terminology.

But that's not all. With a traditional OOP language, if we try to override a private method, the compiler gets angry at us. What happens if we override private methods using this guide's constructs?

// createInternalFunction omitted...

;(function (exports){

const privateMethods = Object.seal({
  _privMethod1() {
    console.log(this.constructor.name, "At base private method 1 on obj", _(this)._privProperty);
  },
});

let _ = createInternalFunction(privateMethods);

class BaseClass {
  constructor(property) {
    _(this)._privProperty = property;
  }

  pubMethod1() {
    console.log(this.constructor.name, "At base public method 1 on obj", _(this)._privProperty);
    _(this)._privMethod1();
  }
}

exports.BaseClass = BaseClass;

})(window);


;(function (exports){

const privateMethods = Object.seal({
  _privMethod1() {
    console.log(this.constructor.name, "At base private method 1 on obj", _(this)._privProperty);
  },
});

let _ = createInternalFunction(privateMethods);

class BaseClass {
  constructor(property) {
    _(this)._privProperty = property;
  }

  pubMethod1() {
    console.log(this.constructor.name, "At base public method 1 on obj", _(this)._privProperty);
    _(this)._privMethod1();
  }
}

exports.BaseClass = BaseClass;

})(window);


;(function (exports){

const privateMethods = Object.seal({
  _privMethod1() {
    console.log(this.constructor.name, "At derived private method 1 on obj", _(this)._privProperty);
  },
});

let _ = createInternalFunction(privateMethods);

class DerivedClass extends BaseClass {
  constructor(property) { // Note: |property| unused here intentionally
    super('base-property');

    console.log(this.constructor.name, "_(this)._privProperty = ", _(this)._privProperty);
  }

  pubMethod2() {
    _(this)._privMethod1();
  }

  pubMethod3(property) {
    _(this)._privProperty = property;
    console.log(this.constructor.name, "_(this)._privProperty = ", _(this)._privProperty);
    this.pubMethod1();
  }
}

exports.DerivedClass = DerivedClass;

})(window);

let obj = new DerivedClass('derived-property');
obj.pubMethod1();
console.log('--');
obj.pubMethod2();
console.log('--');
obj.pubMethod3('new-derived-property');

And the output of the code above is

DerivedClass _(this)._privProperty =  undefined
DerivedClass At base public method 1 on obj base-property
DerivedClass At base private method 1 on obj base-property
--
DerivedClass At derived private method 1 on obj undefined
--
DerivedClass _(this)._privProperty =  new-derived-property
DerivedClass At base public method 1 on obj base-property
DerivedClass At base private method 1 on obj base-property

Essentially, we get no errors, but base class methods will always call base class's private methods, and the derived class methods will always call derived class's private methods. Also likewise, if in a derived class we access a private property with the same name as that of its base class's, things behave as if the two properties are completely unrelated. I call this two-way hiding. In other words: Derived class's methods, public or private, have no means to access base class's private properties or methods; base class methods, public or private, have no means to access derived class's private properties or methods. And we don't get an error for the "no access" part of it, which can be hard to debug.

Well, all hope is not lost yet: Actually, we can ask createInternalFunction() to check if a derived class overrides ("re-defines" may be a better word to use) a private method of its base classes:

;(function (exports){

// class -> class private methods name Set()
let classPrivateMethodsMap = new WeakMap();

// classPrivateMethods -> WeakMap (instance -> members)
let privateMemberMap = new WeakMap();

function createInternalFunction(classType,
                                classPrivateMethods,
                                classPrivatePropertyNames) {
  // guard against outsider mangling
  if (classPrivateMethodsMap.has(classType)) {
    throw new Error(`Class ${classType.name} already encountered in createInternalFunction`);
  }

  let superClassType = Object.getPrototypeOf(classType);

  while (superClassType !== Function.prototype) {
    let superClassPrivateMethodNames = classPrivateMethodsMap.get(superClassType);
    if (superClassPrivateMethodNames) {
      [
       ...Object.keys(classPrivateMethods),
       ...Object.getOwnPropertyNames(classType.prototype)
      ].forEach(methodName =>
      {
        if (superClassPrivateMethodNames.has(methodName)) {
          throw new Error(`Class ${classType.name} defines a method ` +
            `named ${methodName} but base class ${superClassType.name} ` +
            'already defined it as private method');
        }
      });
    }

    superClassType = Object.getPrototypeOf(superClassType);
  }

  classPrivateMethodsMap.set(
    classType,
    new Set(Object.keys(classPrivateMethods))
  );



  if (!privateMemberMap.has(classPrivateMethods)) {
    privateMemberMap.set(classPrivateMethods, new WeakMap());
  }

  function generatePrivateMemberObject(instance) {
    let obj = {};

    Object.entries(classPrivateMethods)
      .forEach(([name, func]) => {
        obj[name] = func.bind(instance);
      });

    return obj;  
  }

  return function(instance) {
    if (!privateMemberMap.get(classPrivateMethods).has(instance)) {
      privateMemberMap.get(classPrivateMethods).set(
        instance,
        generatePrivateMemberObject(instance)
      );
    }

    return privateMemberMap.get(classPrivateMethods).get(instance);   
  }
}

exports.createInternalFunction = createInternalFunction;

})(window);

Essentially, we go upwards the derived class's prototype chain, and check if the derived class's private methods or public methods are re-defining a base class's private method.

Caveats and Further Work for Access Guarding

Since JavaScript is an extremely dynamic and ducky-typing language, there is essentially no way to guard this 100%. We can always instantiate a derived-class object and stuff new member methods into it using the private methods names, and of course, base classes still won't see them.

Also, we can't still guard property assignments, since we don't "define" them in ES6 class syntax. If we want, we can define "known and allowed" private properties like the following snippet, and employ a proxy on the object returned in generatePrivateMemberObject() to check private property assignments.

;(function (exports){

// class -> class private methods name Set()
let classPrivateMethodsMap = new WeakMap();

// class -> class private methods name Set()
let classPrivatePropertiesMap = new WeakMap();

// classPrivateMethods -> WeakMap (instance -> members)
let privateMemberMap = new WeakMap();

function createInternalFunction(classType,
                                classPrivateMethods,
                                classPrivatePropertyNames) {
  // guard against outsider mangling
  if (classPrivateMethodsMap.has(classType)) {
    throw new Error(`Class ${classType.name} already encountered in createInternalFunction`);
  }

  let superClassType = Object.getPrototypeOf(classType);

  while (superClassType !== Function.prototype) {
    let superClassPrivateMethodNames = classPrivateMethodsMap.get(superClassType);
    if (superClassPrivateMethodNames) {
      [
       ...Object.keys(classPrivateMethods),
       ...Object.getOwnPropertyNames(classType.prototype)
      ].forEach(methodName =>
      {
        if (superClassPrivateMethodNames.has(methodName)) {
          throw new Error(`Class ${classType.name} defines a method ` +
            `named ${methodName} but base class ${superClassType.name} ` +
            'already defined it as private method');
        }
      });
    }

    let superClassPrivatePropertyNames = classPrivatePropertiesMap.get(superClassType);
    if (superClassPrivatePropertyNames) {
      classPrivatePropertyNames.forEach(propertyName =>
      {
        if (superClassPrivatePropertyNames.has(propertyName)) {
          throw new Error(`Class ${classType.name} defines a private property ` +
            `named ${propertyName} but base class ${superClassType.name} ` +
            'already defined it as private property');
        }
      });
    }

    superClassType = Object.getPrototypeOf(superClassType);
  }

  classPrivateMethodsMap.set(
    classType,
    new Set(Object.keys(classPrivateMethods))
  );

  classPrivatePropertiesMap.set(
    classType,
    classPrivatePropertyNames
  );



  if (!privateMemberMap.has(classPrivateMethods)) {
    privateMemberMap.set(classPrivateMethods, new WeakMap());
  }

  function generatePrivateMemberObject(instance) {
    let obj = {};

    Object.entries(classPrivateMethods)
      .forEach(([name, func]) => {
        obj[name] = func.bind(instance);
      });

    return obj;  
  }

  return function(instance) {
    if (!privateMemberMap.get(classPrivateMethods).has(instance)) {
      privateMemberMap.get(classPrivateMethods).set(
        instance,
        generatePrivateMemberObject(instance)
      );
    }

    return privateMemberMap.get(classPrivateMethods).get(instance);   
  }
}

exports.createInternalFunction = createInternalFunction;

})(window);


;(function (exports){

const privateMethods = Object.seal({
  _privMethod1() {
    console.log(this.constructor.name, "At base private method 1 on obj", _(this)._privProperty);
  },
});

const privatePropertyNames = Object.seal(new Set([
  '_privProperty'
]));

class BaseClass {
  constructor(property) {
    _(this)._privProperty = property;
  }

  pubMethod1() {
    console.log(this.constructor.name, "At base public method 1 on obj", _(this)._privProperty);
    _(this)._privMethod1();
  }
}

let _ = createInternalFunction(BaseClass, privateMethods, privatePropertyNames);

exports.BaseClass = BaseClass;

})(window);


;(function (exports){

// You may need to comment out re-definitions on private members to make this
// class work, since current code demonstrates how re-definitions on private
// members are disallowed.

const privateMethods = Object.seal({
  // _privMethod1() {
  //   console.log(this.constructor.name, "At derived private method 1 on obj", _(this)._privProperty);
  // },
});

const privatePropertyNames = Object.seal(new Set([
  '_privProperty'
]));

class DerivedClass extends BaseClass {
  constructor(property) {
    super('base-property');

    console.log(this.constructor.name, "_(this)._privProperty = ", _(this)._privProperty);
  }

  // _privMethod1() {

  // }

  pubMethod3(property) {
    _(this)._privProperty = property;
    console.log(this.constructor.name, "_(this)._privProperty = ", _(this)._privProperty);
    this.pubMethod1();
  }
}

let _ = createInternalFunction(DerivedClass, privateMethods, privatePropertyNames);

exports.DerivedClass = DerivedClass;

})(window);


let obj = new DerivedClass('derived-property');
console.log('--');
obj.pubMethod3('new-derived-property');

The proxy part is not included here and will be the exercise for the readers, as I believe we would be patching the language excessively by requiring properties to be defined. (The property-checking code snippet is also not included in my Gist, as I think it's already too much patching.)

Ending Remarks

Hope this guide helps! Indeed, there are still areas we haven't ventured into: For example, what about getters and setters --- especially if we want a property to have a public getter and a private setter? Do things still work with objects that have been modified with Object.defineProperty() and the like? I hope these use case will play nicely with the constructs I introduced here. Any thoughts, ideas or critics are welcome!

Also, the constructs in this guide work flawlessly with traditional (non ES6 syntactic sugar-coated) "classes": No change needed for createInternalFunction().

發表迴響

你的電子郵件位址並不會被公開。 必要欄位標記為 *