Disabling JavaScript Object Extensions

As restrictive data types go, JavaScript objects are settlers on the far edge of the wild west. If we defined a simple object, and later decide that we need to add more properties, we just do it. It won’t even affect an instanceof check.

function User (attrs) {
  this.id = attrs.id;
  this.name = attrs.name;
}

var jill = new User({
  id: 42,
  name: 'Jill'
});

jill.age = 26;
Object.keys(jill) // ['id', 'name', 'age']

jill instanceof User // true

The same goes if we need to remove an existing property–still no trouble at all.

delete jill.name;
Object.keys(jill)    // ['id', 'age']
jill instanceof User // true

At first, this seems terribly convenient. Need to alter an object? Just slap the change right over the top. Unfortunately, fast-and-loose mutability comes with a big downside: consistency.

In our example, we know by definition that all User instances will initialize with an id and name. But after a few properties have been added ad-hoc here, or an instance has been extended there, it’s anyone’s guess what any particular instance of Useractually contains. Even when we apply JavaScript’s last, best panacea–discipline–and restrict mutations to the smallest surface area possible, mistakes are still bound to happen.

What if we could change the rules on object extensions? And what if we could force exceptions any time someone tries to break them? Our assumptions about what a User is would become much safer, for one. We would see mistakes as we made them and correct the offending implementations.

Can you guess where this is going?

ECMAScript 5 gave us several notable (but under-used) tools for managing access to objects and their properties. Starting at the object level and drilling down to individual properties, let’s explore how they can help manage extensibility and encourage more predictable code.

Protecting Objects

We’ll start by applying blanket policies to entire objects. The Object spec exposes three methods to help:

  • Object.preventExtensions(obj) – no new properties may be added to obj but existing properties may be configured–more on that in a bit–or assigned new values
  • Object.seal(obj) – no new properties may be added to obj and existing properties are not configurable, but existing writable properties may be assigned new values
  • Object.freeze(obj) – no new properties may be added to obj, existing properties may not be configured or changed

Before continuing on, it’s worth noting a double-edged sword that all three methods share. On the one hand, we have a tool for describing mutability. On the other, the wild west has become suddenly…civilized. If existing code depends on being able to change a frozen object, the attempt will be discarded. Even worse, it won’t fail quite how we expect. One quirk of Object.freeze and its ilk is that the default behavior will simply ignore invalid requests. No warning, nothing: try to update the value of a frozen object, and the interpreter will simply pretend it didn’t hear.

Fortunately, we can make failure louder. If strict mode is enabled by 'use strict', all three methods will throw instances of TypeError if an attempt is made to alter a protected object.

That’s worth saying again:

Object.freeze and its ilk will cause mutations to silently fail unless strict mode ("use strict") is enabled. Is strict mode enabled? Enable strict mode.

Once we’re in strict mode, object-level policies behave exactly as we would expect:

var jack = new User({
  id: 43,
  name: 'Jack'
});

Object.preventExtensions(jack);

jack.age = 57 // `TypeError` in strict mode
Object.keys(jack) // [ 'id', 'name' ]

Protecting Properties

Object-level protection is nice, but what if we want control different properties separately? Maybe we’d like to lock down the user’s id, for instance, but allow the name to change. Let’s shake up the original User function to set property access explicitly:

function User2 (attrs) {
  var props = {
    id: {
      value: attrs.id,
      writable: false
    },
    name: {
      value: attrs.name
    }
  };

  this.defineProperties(props);
}

In this example, props is something different–a list of property descriptors. Explicitly defining properties either as the second argument to Object.create or by calling defineProperties directly gives us an opportunity to describe how they should behave.

We can actually do this one of two ways: either by using the declarative syntax from the example above or by defining custom getters and setters. In either case we can further detail the behavior of the property by indicating whether it is enumerable (property will show up when keys are accessed by Object.keys or a for..in construct) and configurable.

Declarative Properties

In the example above, the id property’s behavior is declared read-only by the presence of the writable field. Two flags may be set to describe a property’s mutability:

  • writable: the property may be re-assigned
  • configurable: an object’s properties may reconfigured after they have been defined

Both are Boolean values; both default true.

Get and Set

If we need finer control over property assignment and retrieval, we can alternatively supply custom get and set methods when defining a property. Why would we ever want that? Perhaps we’d like to validate a user’s name before we assign it:

function Author (attrs) {
  var familyName = attrs.familyName,
      givenName = attrs.givenName;

  Object.defineProperties(this, {
    givenName: {
      get: function () {
        return givenName;
      },
      set: function (n) {
        if (!n) throw new Error('Name may not be empty');
        givenName = n.toString();
      }
    },
    fullName: {
      get: function () {
        return givenName + ' ' + familyName;
      }
    }
  });
}

This behaves exactly as we expect:

var joseph = new Author({
  familyName: 'Heller',
  givenName: 'Joseph'
});

try {
  joseph.givenName = '';
} catch (e) {
  e // Error: Name may not be empty
  joseph.givenName = 'Joseph'
  joseph.givenName // "Joseph"
}

joseph.fullName // 'Joseph'

The fullName, too, behaves predictably; since no set method is provided, the property is not writable.

joseph.fullName = 'Washington Irving'; // `TypeError` in strict mode
joseph.fullName // 'Joseph'

Reference

This notes draws heavily on MDN’s Object Documentation, which provides much more detail on the methods sketched out here.

Let’s keep in touch

Get noise-free updates on software, product, and process.

Hey, I'm RJ: digital entomologist and intermittent micropoet, writing from the beautiful Rose City.