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!
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.
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. nextGame[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:
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.
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 flagsBefore 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.
Object.freeze
vs. Object.seal
for object immutabilityNow, let’s take a look at the freeze
and seal
methods.
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.
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.
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 concernsTo 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.
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.
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.
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 see exactly what the user did that led to an error.
LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
One Reply to "JavaScript object immutability: Object.freeze vs. Object.seal"
jarOfWine[7] = “a”; should be nextGame[7] = “a”; – within ‘Primitives vs Objects’ first code example