Jemimah Omodior Web developer and technical writer.

JavaScript object immutability: Object.freeze vs. Object.seal

9 min read 2607

JavaScript Object Immutability: Object.freeze vs. Object.seal

When working with values and objects in JavaScript, you may sometimes need to restrict what can be done with them to prevent changes to application-wide configuration objects, state objects, or global constants.

Functions with access to such data may modify them directly when they should not (and this can also stem from unintentional mistakes made by developers). Additionally, other developers working on the same codebase (or using your code) may make such changes unexpectedly.

JavaScript thankfully provides a few constructs to handle these kinds of situations.

In this tutorial, we’ll discuss the concept of immutability and the freeze() and seal() object methods in JavaScript. We’ll see how they work using illustrative code samples and discuss possible performance limitations. Now, let’s get to it!

Understanding immutability in JavaScript

In brief, making an object immutable means that further changes to it will not apply. Essentially, its state becomes read-only. This is, to an extent, what the const keyword achieves:

const jarOfWine = "full";

// throws error "Uncaught TypeError: Assignment to constant variable."
jarOfWine = "empty";

But of course, we can’t use const for entities such as objects and arrays because of how const declarations work — it simply creates a reference to a value. To explain this, let’s review the JavaScript data types.

Primitives vs. objects

The first set of data types are values that consist of just one item. These include primitives such as strings or numbers that are immutable:

let nextGame = "Word Duel";

// change to "Word Dual"? Doesn't stick.
jarOfWine[7] = "a";

nextGame; // still "Word Duel"

// Of course, if we'd declared nextGame with `const`, then we couldn't reassign it.
nextGame = "Word Dual";

nextGame; // now "Word Dual"

When we copy these primitive types, we’re copying values:

const jarOfWine = "full";

const emptyJar = jarOfWine; // both jars are now 'full'

Both variables, jarOfWine and emptyJar, now contain two separate strings, and you can change any one of them independently of the other. However, objects behave differently.

When you declare an object, like in the following code, the user variable does not contain the object itself, but a reference to it:

We made a custom demo for .
No really. Click here to check it out.

const user = {
  name: "Jane",
  surname: "Traveller",
  stayDuration: "3 weeks",
  roomAssigned: 1022,
}

It’s like writing down the address to the cave containing your pile of gold. The address isn’t the cave. So, when we attempt to copy an object using the same method of assignment as when we copied strings, we end up copying just the reference or address and we don’t have two separate objects:

const guest = user;

Modifying user also changes guest:

guest.name = "John";

// now both user and guest look like this:
{
  name: "John",
  surname: "Traveller",
  stayDuration: "3 weeks",
  roomAssigned: 1022,
}

You can usually test this with the Object.is() method or the strict equality operator:

Object.is(user, guest) // returns true

user === guest // returns true

It’s a similar play with the const keyword. It creates a reference to a value, meaning that although the binding cannot change (that is, you cannot reassign the variable), the value referenced can change.

This occured when we successfully modified the name property earlier, even though guest was declared with const:
<

guest.name = "John";

In other words, what const gives us is assignment immutability, not value immutability.

Restricting changes to object properties and entire objects

Since objects in JavaScript are copied by reference, there’s always the risk that copied references mutate the original object. Depending on your use case, such behavior may not be desirable. In that case, it may make sense to essentially “lock down” the object.

(Ideally, you’d make copies of your object and modify those, rather than the original object. While most copying or cloning mechanisms are shallow, if you’re working with deeply nested objects, then you’d want deep cloning.)

JavaScript provides three methods that perform varying levels of access restriction to objects. These include Object.freeze(), Object.seal(), and Object.preventExtensions(). Although we’ll cover the latter somewhat, we’ll focus mostly on the former two.

writable and configurable property flags

Before we move on, however, let’s walk through some underlying concepts behind the mechanisms that limit access to properties. Specifically, we’re interested in property flags, such as writable and configurable.

You can typically check the values of these flags when using the Object.getOwnPropertyDescriptor or Object.getOwnPropertyDescriptors methods:

const hunanProvince = {
  typeOfWine: "Emperor's Smile",
};

Object.getOwnPropertyDescriptors(hunanProvince);

// returns
{
  typeOfWine: {
    value: "Emperor's Smile",
    writable: true,
    enumerable: true,
    configurable: true
  },
}

Although we’re usually more concerned with the actual values of our properties when we work with JavaScript objects, properties have other attributes in addition to the value attribute, which holds the value of the property.

These include the already mentioned value, writable, and configurable attributes, as well as enumerable, as seen above.

The writable and configurable flags are the most important to us. When writable is set to true for a property, its value can change. Otherwise, it’s read-only.

Then there’s configurable, which, when set to true on a property, lets you make changes to the aforementioned flags or delete a property.

If configurable is instead set to false, everything essentially becomes read-only with one exception: if writable is set to true where configurable is false, the value of the property can still change:

Object.defineProperty(hunanProvince, "capital", {
  value: "Caiyi Town",
  writable: true,
});

hunanProvince.capital = "Possibly Gusu";

Object.getOwnPropertyDescriptors(hunanProvince);
// now returns
{
  typeOfWine: {
    value: "Emperor's Smile",
    writable: true,
    enumerable: true,
    configurable: true
  },
  capital: {
    value: "Possibly Gusu",
    writable: true,
    enumerable :false,
    configurable: false
  },
}

Note that enumerable and configurable are both false for the capital property here because it was created with Object.defineProperty(). As mentioned earlier, properties created this way have all flags set to false. However writable is true because we set that explicitly.

We’re also allowed to change writable from true to false, but that’s it. You can’t alter it from false to true. In fact, once both configurable and writable are set to false for a property, no further changes to it are allowed:

Object.defineProperty(hunanProvince, "capital", {
  writable: false,
  // everything else also `false`
});

// no effect
hunanProvince.capital = "Caiyi Town";

While these flags are used here on a property level, methods like Object.freeze() and Object.seal() work on an object level. Let’s move on to that now.

This article assumes you have a general knowledge of why the concept of immutability is useful.

However, if you’d like to dig deeper and read some arguments for and against it, here’s a really handy StackOverflow thread (with links to additional resources) that discusses the topic. The Immutable.js docs also make a case for immutability.

Using Object.freeze vs. Object.seal for object immutability

Now, let’s take a look at the freeze and seal methods.

Using Object.freeze

When we freeze an object using Object.freeze, it can no longer be modified. Essentially, new properties can no longer be added to it and existing properties cannot be removed. As you can guess, this is achieved by setting all flags to false for all properties.

Let’s walk through an example. Here are the two objects we’ll work with:

let obj1 = {
  "one": 1,
  "two": 2,
};

let obj2 = {
  "three": 3,
  "four": 4,
};

Now, let’s change a property in the first object, obj1:

obj1.one = "one"; // returns "one"

So, the original object now looks like this:

obj1;

{
  one: "one",
  two: 2,
};

Of course, this is expected behavior. Objects are alterable by default. Now, let’s try freezing an object. We’ll work with obj2 since it hasn’t been tampered with yet:

// freeze() returns the same object passed to it
Object.freeze(obj2); // returns {three: 3, four: 2}

// test
obj2 === Object.freeze(obj2); // returns true

To test that an object is frozen, JavaScript provides the Object.isFrozen() method:

Object.isFrozen(obj2); // returns true

Now, even if we attempted to modify it like the following, there is no effect.

obj2.three = "three"; // no effect

However, as we’ll see soon, we’ll run into trouble when we start using nested objects. Like object cloning, freezing can also be shallow or deep.

Let’s create a new object from obj1 and obj2 and nest an array in it:

// nesting
let obj3 = Object.assign({}, obj1, obj2, {"otherNumbers": {
  "even": [6, 8, 10],
  "odd": [5, 7, 9],
}});

obj3;
// {
//    one: "one",
//    two: 2,
//    three: 3,
//    four: 4,
//    "otherNumbers": {
//      "even": [6, 8, 10],
//      "odd": [5, 7, 9],
//    }
//  }

You’ll notice that even when we freeze it, we can still make changes to the arrays in the nested object:

Object.freeze(obj3);

obj3.otherNumbers.even[0] = 12;

obj3;
// {
//    one: "one",
//    two: 2,
//    three: 3,
//    four: 4,
//    "otherNumbers": {
//      "even": [12, 8, 10],
//      "odd": [5, 7, 9],
//    }
//  }

The even number array now has its first element modified from 6 to 12. Since arrays are also objects, this behavior comes up here as well:

let testArr = [0, 1, 2, 3, [4, 5, [6, 7]]];

Object.freeze(testArr);

testArr[0] = "zero"; // unable to modify top-level elements...

// ...however, nested elements can be changed

testArr[4][0] = "four"; // now looks like this: [0, 1, 2, 3, ["four", 5, [6, 7]]]

If you’ve been testing out your code in the browser console, it likely failed silently and didn’t throw any errors. If you’d like the errors to be more explicit, try wrapping your code in an Immediately Invoked Function Expression (IIFE) and turn on strict mode:

(function() {
  "use strict";

  let obj = {"one": 1, "two": 2};

  Object.freeze(obj);

  obj.one = "one";
})();

The above code should now throw a TypeError in the console:

Uncaught TypeError: Cannot assign to read only property 'one' of object '#<Object>'

Now, how do we make our entire object, including top-level (direct property references) and nested properties, frozen?

As we’ve noted, freezing is only applied to the top-level properties in objects, so a deepFreeze() function that freezes each property recursively is what we want:

const deepFreeze = (obj) => {
  // fetch property keys
  const propKeys = Object.getOwnPropertyNames(obj);

  // recursively freeze all properties
  propKeys.forEach((key) => {
    const propValue = obj[key];

    if (propValue && typeof(propValue) === "object") deepFreeze(propValue);
  });

  return Object.freeze(obj);
}

Now, attempts to mutate the nested properties are unsuccessful.

Note that while freezing essentially guards against changes to objects, it does allow variable reassignment.

Using Object.seal()

With Object.freeze(), new changes have no effect on the frozen object. However, the seal() method allows modifying existing properties. This means that while you cannot add new properties or remove existing ones, you can make changes.

The seal() method basically sets the configurable flag we discussed earlier to false, with writable set to true for each property:

const students = {
  "001" : "Kylie Yaeger",
  "002": "Ifeoma Kurosaki"
};

// seal object
Object.seal(students);

// test
Object.isSealed(students); // returns true

// cannot add or delete properties
students["003"] = "Amara King"; // fails
delete students["001"]; // fails

Here’s another example with an array:

const students = ["Kylie Yaeger", "Ifeoma Kurosaki"];

// seal
Object.seal(students);

// test
Object.isSealed(students); // returns true

// throws a TypeError saying object is not extensible
students.push("Amara King");

Sealing also prevents redefining a property with the use of Object.defineProperty() or Object.defineProperties(), whether you’re adding a new property or modifying an existing one.

Remember, however, that if writable is true, you may still change it to false, but this cannot be undone.

// fails
Object.defineProperty(hunanProvince, "capital", {
  value: "Unknown",
  writable: true,
});

Another change sealing makes impossible is changing normal data properties into accessors (that is, getters and setters):

// fails
Object.defineProperty(hunanProvince, "capital", {
  get: () => "Caiyi Town",
  set: (val) => hunanProvince["capital"] = val;
});

The reverse is also the case: you cannot change accessors into data properties. Just as with freezing, sealing an object prevents its prototype from changing:

const languageSymbols = {
  English: "ENG",
  Japanese: "JP",
  French: "FR",
};

const trollLanguageSymbols = {
  trollEnglish: "T-ENG",
  trollJapanese: "T-JP",
  trollFrench: "T-FR",
};

Object.seal(trollLanguageSymbols);

// fails
Object.setPrototypeOf(trollLanguageSymbols, languageSymbols);

Again, just as with freezing, the default behavior here is shallow sealing. So, you can choose to deep-seal an object in the same way as you can deep-freeze one:

const deepSeal = (obj) => {
  // fetch property keys
  const propKeys = Object.getOwnPropertyNames(obj);

  // recursively seal all properties
  propKeys.forEach((key) => {
    const propValue = obj[key];

    if (propValue && typeof(propValue) === "object") deepSeal(propValue);
  });

  return Object.seal(obj);
}

We’ve modified MDN’s deepFreeze() function here to perform sealing instead:

const students = {
  "001" : "Kylie Yaeger",
  "002": "Ifeoma Kurosaki",
  "003": {
    "004": "Yumi Ren",
    "005": "Plisetsky Ran",
  },
};

deepSeal(students);

// fails
delete students["003"]["004"];

Now, our nested objects are also sealed.

Using Object.preventExtensions()

Another JavaScript method that can specifically prevent adding new properties is the preventExtensions() method:

(() => {
  "use strict";

  const trollToken = {
    name: "Troll",
    symbol: "TRL",
    decimal: 6,
    totalSupply: 100_000_000,
  };

  Object.preventExtensions(trollToken);

  // fails
  trollToken.transfer = (_to, amount) => {}
})();

Since all we’re doing is preventing adding new properties, existing ones can obviously be modified and even deleted:

delete trollToken.decimal;

trollToken;

// {
//    name: "Troll",
//    symbol: "TRL",
//    totalSupply: 100_000_000,
//  }

Something to note is that the [[prototype]] property becomes immutable:

const token = {
  transfer: () => {},
  transferFrom: () => {},
  approve: () => {},
};

// fails with a TypeError
Object.setPrototypeOf(trollToken, token);

To test whether an object is extensible, simply use the isExtensible() method:

// I've omitted `console.log` here since I'm assuming you're typing in the browser console directly
(`Is trollToken extensible? Ans: ${Object.isExtensible(trollToken)}`);

Just like when we manually set the configurable and writable flags to false for a property, making an object inextensible is a one-way road.

Object.freeze and Object.seal use cases and performance concerns

To summarize, Object.freeze() and Object.seal()are constructs provided by the JavaScript language to help maintain varying levels of integrity for objects. However, it can be quite confusing to understand when one would need to use these methods.

One example mentioned earlier is the use of global objects for application state management. You may want to keep the original object immutable and make changes to copies, especially if you’d like to keep track of state changes and revert them.

Freezing defends against code attempting to mutate objects that should not be modified directly.

Frozen or sealed objects can also prevent the addition of new properties that are introduced due to typos, such as mistyped property names.

These methods help when debugging as well because the restrictions placed on objects can help narrow down possible sources of bugs.

That said, it can be a source of headache for anyone using your code since there is essentially no physical difference between a frozen object and a non-frozen one.

The only way to know for certain that an object is frozen or sealed is to use the isFrozen() or isSealed() methods. This can make it somewhat difficult to reason about expected object behavior because it may not be entirely obvious why such restrictions were put in place.

Tagged templates are one feature that use Object.freeze() implicitly; the styled-components library and a few others rely on it. The former uses tagged template literals to create its styled components.

If you’re wondering what — if any — performance costs exist when using any of the above-discussed methods, there were some historical performance concerns in the V8 engine. However, this was more a bug than anything else, and it’s since been fixed.

Between 2013 and 2014, both Object.freeze() and Object.seal() also underwent some performance improvements in V8.

Here’s a StackOverflow thread that tracked the performance of frozen objects vs. non-frozen objects between 2015 and 2019. It shows that performance in both cases is pretty much the same in Chrome.

Still, it’s possible sealing or freezing may impact an object’s enumeration speed in certain browsers like Safari.

Third-party libraries for handling immutability

There are multiple ways to handle immutability in JavaScript. While the methods discussed above can be handy to have around, you’ll most likely reach for a library for any substantial application.

Examples include Immer and Immutable.js. With Immer, you use the same JavaScript data types you already know. However, although Immutable.js introduces new data structures, it can be the faster option.

Conclusion

JavaScript provides methods such as Object.freeze() and Object.seal() for varying levels of access restriction for objects.

However, just as with cloning, because objects are copied by reference, freezing is usually shallow. Therefore, you can either implement your own basic deep freeze or deep seal functions or, depending on your use case, take advantage of libraries such as Immer or Immutable.js.

: Debug JavaScript errors more easily by understanding the context

Debugging code is always a tedious task. But the more you understand your errors the easier it is to fix them.

LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to find out exactly what the user did that led to an error.

LogRocket records console logs, page load times, stacktraces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!

.
Jemimah Omodior Web developer and technical writer.

Leave a Reply