Immutable Classes in JavaScript

"You can't touch this!" — MC Hammer

As the JavaScript matures and starts being used to build ever bigger projects, it might make sense to adapt some things that are generally accepted in the industry as best practices.

One of those things is the usage of immutable objects throughout the application. If you are familiar with Object.freeze, the solution might seem straightforward. As it happens with many things in JavaScript, there are some tricky things to keep in mind.

The Basics of Object.freeze

Let’s start with a canonical example of freezing:

// strict mode is important, as otherwise instead of a TypeError
// you will just have a silent failure

'use strict'

const obj = {
  answer: 42
}

Object.freeze(obj)

obj.answer = 43 // throws TypeError about read only property
obj.newProperty = 'foo' // throws TypeError about object not being extensible
Object.defineProperty(obj, 'bar', {value: 'bar'}) // the same TypeError

If we are talking about immutable objects, these seems to do the trick. There are a couple of additional things, that do not work out of the box however. Firstly, If you have nested objects in the literal, you need to freeze them as well:

'use strict'

const obj = {
  foo: {
    bar: 'buzz'
  }
}

Object.freeze(obj)
obj.foo = {} // throws TypeError about read only property
obj.foo.bar = 'works'
Object.freeze(obj.foo)
obj.foo.bar = 'throws' // throws TypeError about read only property

The other issues is related to prototypes and can not be solved that easily:

'use strict'

const obj = {
  foo: 'foo'
}
const proto = {
  bar: 'bar'
}

Object.setPrototypeOf(obj, proto)
Object.freeze(obj)

obj.foo = 'modified foo' // throws TypeError about read only property
proto.bar = 'modified bar'
console.log(obj.bar) // 'modified bar'

If you want to understand why Object.freeze works the way it does, the best place to look is ECMAScript Language Specification. In particular I’m referring to the part about Object.freeze and SetIntegrityLevel which is an internal call that gets executed when you run Object.freeze.

Since it takes some skill and practice to read algorithms specified in pseudo-code, below you can find it transcribed to JavaScript with reference to each point of the original specification:

Object.freeze = function (O) { // ECMAScript 19.1.2.5
  if (typeof O !== 'object') return O // 1.
  const status = SetIntegrityLevel(O, 'frozen') // 2.
  // 3. ReturnIfAbrupt is handled on engine level
  if (status === false) throw new TypeError('Could not freeze object') // 4.
  return O // 5.
}

function SetIntegrityLevel (O, level) { // ECMAScript 7.3.14
  assert(typeof O === 'object') // 1.
  assert(level === 'sealed' || level === 'frozen') // 2.
  Object.preventExtensions(O) // 3.
  // 4. ReturnIfAbrupt is handled on engine level
  // 5. status is handled on engine level as well
  const keys = Object.getOwnPropertyNames(O) // 6.
  // 7. ReturnIfAbrupt is handled on engine level
  if (level === 'sealed') { // 8.
    keys.forEach(function (k) { // a.
      Object.defineProperty(O, k, Object.assign(Object.getOwnPropertyDescriptor(O, k), {
        configurable: false
      })) // i.
      // ii. ReturnIfAbrupt is handled on engine level
    })
  } else { // 9.
    keys.forEach(function (k) { // a.
      const currentDesc = Object.getOwnPropertyDescriptor(O, k) // i.
      // ii. ReturnIfAbrupt is handled on engine level
      if (currentDesc !== undefined) {
        let desc
        if (IsAccessorDescriptor(currentDesc)) { // 1.
          desc = Object.assign(Object.getOwnPropertyDescriptor(O, k), {
            configurable: false
          }) // a.
        } else { // 2.
          desc = Object.assign(Object.getOwnPropertyDescriptor(O, k), {
            configurable: false,
            writable: false
          }) // a.
        }
        Object.defineProperty(O, k, desc) // 3.
        // 4. ReturnIfAbrupt is handled on engine level
      }
    })
  }
  return true // 10.
}

function IsAccessorDescriptor (desc) { // ECMAScript 6.2.4.1
  if (desc === undefined) return false // 1.
  if (!('get' in desc) && !('set' in desc)) return false // 2.
  return true
}

function assert(condition) {
  if (!condition) throw new Error('Assertion Failed')
}

The code above while being as much as possible true to the spec is a little bit hard to read. If we get rid of all the assertions, and focus on freeze part of the algorithm, it becomes pretty straightforward: first thing is to make sure that we can not add new properties to the object with Object.preventExtensions (sadly it’s not easily implementable in JavaScript itself), then we update all the object properties and make sure they are not configurable (i.e. you can not override them by calling Object.defineProperty) and in case it’s not an accessor (no setter or getter), we also make sure you can not overwrite the value by just assigning to that property, which is done setting writable to false. Here’s the simplified code:

Object.freeze = function (object) {
  Object.preventExtensions(object)
  const keys = Object.getOwnPropertyNames(object)
  keys.forEach(function (propertyName) {
    const currentDesc = Object.getOwnPropertyDescriptor(object, propertyName)
    currentDesc.configurable = false
    if (!(currentDesc.get || currentDesc.set)) {
      currentDesc.writable = false
    }
    Object.defineProperty(object, propertyName, currentDesc)
  })
  return object
}

Usage with Classes

In the previous section you saw how the Object.freeze works with the object literals and what it does internally. For simple classes you can just Object.freeze as the last line in your constructor:

'use strict'

class Point2d {
  constructor (x, y) {
    this.x = x
    this.y = y
    Object.freeze(this)
  }
}

const p = new Point2d(10, 2)
p.x = 2 // throws TypeError about read only property

The trouble comes when we try to use subclassing:

'use strict'

class Point3d extends Point2d {
  constructor (x, y, z) {
    super(x, y)
    this.z = z
    Object.freeze(this)
  }
}

// throws TypeError about adding `z` to a non-extensible object
const p = new Point3d(1, 2, 3)

As described in the previous section, the first thing Object.freeze does is prevent extension for the object, so it is natural that when we want to add another property (z), we get an exception.

The way to deal with this issue revolves around checking which constructor (child or parent) was used with new. The canonical way to access this information in ECMAScript 2015+ code is to use new.target keyword.

In engines not supporting new.target you can fallback to using this.constructor with a drawback being that the code will break if one of your classes overwrites this property to something else in its constructor.

The only thing we need to do is wrap each Object.freeze call in a check that invoked constructor is indeed the one we are in right now:

class Point2d {
  constructor (x, y) {
    this.x = x
    this.y = y

    if (new.target === Point2d) {
      Object.freeze(this)
    }
  }
}

class Point3d extends Point2d {
  constructor (x, y, z) {
    super(x, y)
    this.z = z
    if (new.target === Point3d) {
      Object.freeze(this)
    }
  }
}

const p = new Point3d(1, 2, 3) // works!
p.z = 42 // throws TypeError about read only property

The only real problem is that since new.target is a keyword (similar to arguments) and it needs to run after all of the constructor code is done, we can not simply abstract it away in the superclass or even a function. It is possible to do it by decorating a class.

As of the time of writing decorators proposal is being updated to the new version and does not have sufficient detail regarding decorating classes, so I'm using a plain js implementation.

function immutable (Original) {
  const Immutable = class extends Original {
    constructor (...args) {
      super(...args)
      if (new.target === Immutable) {
        Object.freeze(this)
      }
    }
  }
  return Immutable
}

const Point2d = immutable(class Point2d {
  constructor (x, y) {
    this.x = x
    this.y = y
  }
})

const Point3d = immutable(class Point3d extends Point2d {
  constructor (x, y, z) {
    super(x, y)
    this.z = z
  }
})

const p = new Point3d(1, 2, 3)

p.z = 42 // throws TypeError about read only property

This is much better, but the error message now is quite cryptic:

TypeError: Cannot assign to read only property 'z' of object '#<Immutable>'

Ideally, this should solvable by updating the name of the class to that of the original:

Object.defineProperty(
  Immutable, 'name',
  Object.getOwnPropertyDescriptor(Original, 'name')
)

Unfortunately this does not seem to work, at least on V8 (node.js and Chrome). The only solution that works is using eval, which looks slightly scary, but does the job:

function immutable (Original) {
  return eval(`Original => class ${Original.name} extends Original {
    constructor (...args) {
      super(...args)
      if (new.target === ${Original.name}) {
        Object.freeze(this)
      }
    }
  }`)(Original)
}

Now we have proper error messages:

TypeError: Cannot assign to read only property 'z' of object '#<Point3d>'

Curious Case of Subclassing Null

In the section about Object.freeze I noted that the nature of prototypal inheritance in JavaScript effectively makes your classes extensible by changing prototype of the Object. So the following declaration of Point2d is fully equivalent to the one before:

class Point2d extends Object {
  constructor (x, y) {
    super()
    this.x = x
    this.y = y
  }
}

This means that to not inherit any methods and properties from the Object, we need to replace it with something else that doesn’t have any. In JavaScript that something is null. Unfortunately this does not work:

class Point2d extends null {
  constructor (x, y) {
    super()
    this.x = x
    this.y = y
  }
}

The reason is that if we leave call to super(), then we trying to call null as a function, and if we remove it, as per language specification, the runtime will complain that we are not calling super.

The solution is to update the prototype outside of the class definition:

class NullObject {}
Object.setPrototypeOf(NullObject.prototype, null)

const Point2d = immutable(class Point2d extends NullObject {
  constructor (x, y) {
    super()
    this.x = x
    this.y = y
  }
})

const p = new Point2d(1, 2)
p.toString() // TypeError p.toString is not a function

Now we get correct inheritance, which means that methods defined on Object, like toString are no longer available and throw a TypeError. If you want to keep some (or all) of properties from object, you can do so:

class NullObject {}
Object.setPrototypeOf(NullObject.prototype, null)
Object.getOwnPropertyNames(Object.prototype).forEach(key => {
  NullObject.prototype[key] = Object.prototype[key]
})

// ... same Point2d implementation

const p = new Point2d(1, 2)
p.toString() // returns '[object Object]'

The only problem I could discover with this approach is that debug messages for setting readonly properties like p.x = 42 are now always saying [object Object], which is not very nice. I’ve created an issue in V8 tracker about this.

Testing Frozen Objects

When people start working with frozen objects, one of the issues that come up is around stubbing some properties in the tests. The example below uses sinon and mocha, but most libraries work in the same way:

const Point2d = immutable(class Point2d {
  constructor (x, y) {
    this.x = x
    this.y = y
  }
  distanceTo (other) {
    const dx = Math.abs(this.x - other.x)
    const dy = Math.abs(this.y - other.y)
    return Math.sqrt(dx * dx + dy * dy)
  }
})

function complicatedLogic(p1, p2) {
  return p1.distanceTo(p2)
}

describe('complicatedLogic', function () {
  it('should call distanceTo', function () {
    const p1 = new Point2d(1, 2)
    const p2 = new Point2d(3, 4)

    // this will throw a TypeError, because `distanceTo` is read only
    const stub = sinon.stub(p1, 'distanceTo')

    complicatedLogic(p1, p2)
    sinon.assert.called(stub)
  })
})

The way to deal with it is actually the same thing that was causing us problems before — prototype:

describe('complicatedLogic', function () {
  it('should call distanceTo', function () {
    const p1 = new Point2d(1, 2)
    const p2 = new Point2d(3, 4)
    const stubEnabledP1 = Object.create(p1)

    const stub = sinon.stub(stubEnabledP1, 'distanceTo')

    complicatedLogic(stubEnabledP1, p2)
    sinon.assert.called(stub)
  }) // All good!
})

If you are not familiar with Object.create, when called with one argument it just does this:

function objectCreate(prototype) {
  const result = {}
  Object.setPrototypeOf(result, prototype)
  return result
}

That’s it, now you know how to create and test immutable classes in JavaScript. While it might be nice to see a better support for them out of the box, the flexibility of JavaScript allows us to have a decent user-land implementation illustrated in this article.