loading...

ES6 classes with private members

jankapunkt profile image Jan Küster ・5 min read

If you researched a bit into private ES6 class members, you might have found that implementing them with "clean code" is hardly possible. They are even harder to apply on an existing codebase without breaking things. Just read this best of messy code and hacky workarounds from a StackOverflow question:

https://stackoverflow.com/questions/22156326/private-properties-in-javascript-es6-classes

However, we can still implement some privacy while not violating SRP and keep the class code readable, because we wont even touch existing class code.

In this short article, we will make use a mix of a closure, a Proxy and a pattern, close to an abstract factory to create custom private instances of a given example class.

A simple example class

Information hiding in "classic" ES5 code is well-known and elegantly solved, for example using the module pattern or using closures and a factory function This tutorial, however strictly focuses on ES6+ classes.

Our example class contains two primitive members, that are internally accessed within a function:

class Person {
  constructor ({ name, age }) {
    this.name = name
    this.age = age
  }

  greet () {
    return `Hello, my name is ${this.name}. I am ${this.age} years old.`
  }
}

The class is clearly readable and testable but we want to preserve state within the instances after construction - meaning name and age should not be accessible, while the greet function should be.

Log the property access with a Proxy

This section should be of interest for those who are not familiar with using a Proxy.

The proxie's get function is a "trap", which it's invoked every time some code tries to access a property of the target. It can be used to decide, whether the property is available for access or not. Let's check for a moment how this works by logging the property access:

const person = new Person({ name: 'John Doe', age: 42 })
const proxy = new Proxy(person, {
  get: function (target, property, receiver) {
    const value = target[property]
    console.log(`${property} => ${value}`)
    return value
  }
})

Let's see the logged values:

proxy.name // "name => John Doe"
proxy.age // "age => 42"
proxy.greet // "greet => function() greet {}" 

The trap works even when the target aims to access its own members. In our case proxy.greet() will cause three access calls:

proxy.greet() 
// "greet => function() greet {}" 
// "name => John Doe"
// "age => 42"

Restricting access to name and age

The goal is to enable to the access of greet while at the same time preventing the access of name and age. A simple approach is to only return true, in case the property is of type 'function':

const person = new Person({ name: 'John Doe', age: 42 })
const proxy = new Proxy(person, {
  get: function (target, property, receiver) {
    return typeof target[property] === 'function'
  }
})

proxy.name // undefined
proxy.age // undefined
proxy.greet() // Hello, my name is undefined. I am undefined years old.

Huh!? This is not the expected output, since name and age have been resolved as undefined. Fortunately the explanation is as simple as the solution:

The internal access to this.name and this.age in greet is also running through the get trap. Since we allow only function types to be accessible, these properties will resolve to undefined.

The solution is to bind the function properties to the original person in order to circumvent the get trap:

const person = new Person({ name: 'John Doe', age: 42 })
const proxy = new Proxy(person, {
  get: function (target, property, receiver) {
    const member = target[property] 
    if (typeof member === 'function') {
      return member.bind(person)
    }
  }
})

proxy.name // undefined
proxy.age // undefined
proxy.greet() // Hello, my name is John Doe. I am 42 years old.

If you are really concerned about hiding name and age you may also log the proxy itself: console.log(proxy) which will reveal to users in the browser console the original person instance under [[target]]. To get rid of it, you need to pass a plain Object as target to the proxy constructor and internally use the person reference within the get trap:

const person = new Person({ name: 'John Doe', age: 42 })
const proxy = new Proxy({}, {
  get: function (target, property, receiver) {
    const member = person[property] 
    if (typeof member === 'function') {
      return member.bind(person)
    }
  }
})

In our next step we will use some closure code (via factory function) to complete the hiding of any access to the original person instance.

Wrap this code into a factory function

We are able now to create proxies to instances with well-defined property access but we need get rid of person being in scope for external access, otherwise this is all useless. Factory functions to the rescue:

const createPerson = ({ name, age}) => {
  const person = new Person({ name, age })
  return new Proxy({}, {
    get: function (target, property, receiver) {
      const member = person[property] 
      if (typeof member === 'function') {
        return member.bind(person)
      }
    }
  })
}

const anon = createPerson({ name: 'John Doe', age: 42 })
anon.name // undefined
anon.age // undefined
anon.greet() // Hello, my name is John Doe. I am 42 years old.

Let's push it further as we have much more space for abstraction:

  1. If this works for our Person class it should work for any other defined class, too.
  2. If rules are based on a function that resolves to a Boolean, we can pass a function to decide for the privacy level of a member
  3. For creating rules, we should only need the name of the property and it's type, preventing the value from leaking to the outside world

To do this, we surround our factory function by another factory function, creating somewhat of an abstract factory pattern (not 100% the original pattern but it gets close):

const createFactory = (ClassDefiniton, callback) => {
  const factory = (...args) => {
    // create a new instance, let user (dev) manage
    // args validation within the class or before this call
    const instance = new ClassDefiniton(...args)

    // our final product
    return new Proxy({}, {
      get: function (target, property, receiver) {
        const member = target[property] 
        const type = typeof member

        // decide to allow access via callback
        const allowed = callback(property, type)
        if (!allowed) return

        // in case the prop is a function -> bind
        return type === 'function'
          ? member.bind(instance)
          : member
      }
    })
  }
}

// create the new factory, including on our custom rule
const createPrivatePerson = createFactory(Person, (key, type) => type === 'function')

const anon = createPrivatePerson({ name: 'John Doe', age: 42 })
anon.name // undefined
anon.age // undefined
anon.greet() // Hello, my name is John Doe. I am 42 years old.

From here you could write rules to whitelist/blacklist members by name and type. You could for example hide all properties that include and _ in the beginning of their name, a convention that is often used to indicate private properties. Also, beware to not leak the value of the property to external code. The property name and the type should be sufficient for most of the cases.

A final note

I do not want to propose this method as definitely secure. There may be still ways to get access to the original instance or its properties. If you found a way through, please let me know :-)

Furthermore, I'd like to underline, that I have also written a lightweight module and released it to the npm registry that implements the abstract factory nearly the same as the one used in this article:

https://github.com/jankapunkt/js-class-privacy

https://www.npmjs.com/package/class-privacy

Posted on by:

jankapunkt profile

Jan Küster

@jankapunkt

Graduated in Digital Media M.Sc. now developing the next generation of educational software. Since a while I develop full stack in Javascript using Meteor. Love fitness and Muay Thai after work.

Discussion

pic
Editor guide