Creating Immutable Objects in JavaScript
#javascript, #immutability, #objects
Sep 10, 202415 min read
Ever copied an object, tweaked a property, and then realized both the OG and the copy got changed? Yeah, same.
Objects stand as the cornerstone of JavaScript, forming one of its six primary types. By default, objects are mutable — their properties and elements can be freely modified without requiring a complete reassignment. However, while we appreciate this flexibility, at times we may want to maintain data integrity by enforcing immutability.
In this article, we’ll cover:
-
The difference between Mutable and Immutable objects
-
How Primitives and Objects behave in JavaScript
-
The role of Property Descriptors in enforcing Immutability
-
Creating Immutable Objects in JavaScript
Let's dive in and see how embracing immutability can transform the way we work with objects.
Mutable vs Immutable
We’ve been throwing around the terms mutable and immutable, but what does that even mean?
A mutable value is one that can be changed without creating an entirely new value. In JavaScript, objects and arrays are mutable by default, but primitive values are not — once a primitive value is created, it cannot be changed, although the variable that holds it may be reassigned.
To simplify, mutable =
changeable; immutable =
unchangeable. Let’s see how this distinction manifests in JavaScript.
Primitives
All primitives are immutable; they cannot be altered once they are created.
It's important not to confuse a primitive itself with a variable assigned a primitive value. The variable may be reassigned to a new value, but the existing value cannot be changed in the ways that objects can be altered.
This might sound odd, because you might be thinking ‘Not true. I change primitive values all the time.’
Mhmm, not quite.
Let's say we create a variable age
and assign it the value 30
and later, reassign it.
let age = 30;
age = 31;
When we first assign 30
to age
, JavaScript allocates a memory space to store 30
and assigns it to the variable age
. You can think of age
as the label on a box, and inside that box lives the value 30
. The variable is not the box. It's more like a signpost pointing to the box. (Think of age
as the address where the value 30
resides in memory.)
When we reassign age
to the value 31
, JavaScript does not change the existing memory space where 30
was stored. Instead, it creates a new memory space for the value 31
and updates the variable age
to point to this new memory space. The original value 30
remains unchanged.
Once a primitive value is created and stored in memory, it cannot be directly modified. Any attempt to modify a primitive value results in the creation of a new value, leaving the original value unchanged (at some point it is garbage collected, since nothing is using it). So even though we use the same variable age
to store different values, the original value 30
remains intact, and a new value 31
is created in a separate memory space. So, we are never modifying the original value; we're always creating a new value.
This behaviour contrasts with objects, where mutability allows for direct modification of properties and values.
Objects
By default, objects are mutable. We can edit properties and change property values "in place" after an object is created. This means when we update it, JavaScript doesn’t create a new box to store the updated object.
Unlike primitives, which are stored directly in memory and cannot be altered, objects are stored by reference. Therefore, when you create an object and assign it to a variable, the variable doesn't hold the actual object itself, but rather a reference to where the object is stored in memory.
Objects contain properties that act as pointers (technically, references) to where the values are stored.
Consider this object:
const anime = {
name: "demon slayer",
character: "tanjiro",
};
And then lets say we change the value stored at the character
location.
anime.character = 'zenitsu'
// { name: 'demon slayer', character: 'zenitsu' }
Notice that the anime
variable itself never changes. It continues to point to the same memory location, holding the same object. Only one of the object's properties has changed.
This follows the same rules as earlier. When we reassign a property, the primitive value stored at the character
location is replaced with a new value, and character
now points to this new value. The only difference is that these values are within an object, but reassigning a property works in the same way as reassigning a standalone variable.
The crucial thing to understand is that the object itself hasn’t changed. Only the properties within it have been updated.
So, what’s the problem with this?
The Problem with Mutable Objects
When dealing with mutable objects, changes can have unintended side effects, especially in larger applications. This mutability can lead to bugs that are hard to trace and fix, as changes in one part of the code can unexpectedly affect other parts.
I’m sure we’ve all had that WTF moment where we copy an object and edit it’s property only to find that both the original and copied objects were edited.
Let’s revisit the earlier example.
// where we left off
const anime = {
name: "demon slayer",
character: "zenitsu",
};
const copy = anime;
copy.character = "inosuke";
console.log(anime); // { name: 'demon slayer', character: 'inosuke' }
console.log(copy); // { name: 'demon slayer', character: 'inosuke' }
Both anime
and copy
now have character
set to "inosuke". This is because both variables point to the same object in memory. This can lead to unintended side effects and bugs that are difficult to track.
While we appreciate the flexibility of objects, it can be a drawback at times. Instead, we may want to create objects that preserve data integrity through immutability.
Embracing Immutability
Immutable objects are objects whose state cannot be modified after they are created. Instead, you’d have to create a new object with the desired changes.
Immutability offers several significant benefits:
-
Safer and More Reliable Code: By preventing unintended modifications, immutability helps prevent bugs and makes the code more reliable.
-
Easier Debugging: Since immutable objects maintain a consistent state, it’s easier to track changes and debug the code.
-
Predictable State Management: Immutability ensures that the state of an object is predictable, which is particularly useful in functional programming and state management libraries like Redux.
-
Simpler Concurrency Management: In multi-threaded environments, immutable objects eliminate the need for complex synchronization mechanisms because they cannot be modified by different threads. This reduces the chances of data races and concurrency-related bugs.
-
Data Integrity: Immutability ensures that once data is created, it cannot be altered. This guarantees that data remains consistent and reliable throughout the lifecycle of an application.
-
Simpler Reasoning: Immutability makes it easier to reason about your code since objects do not change state, reducing the cognitive load on developers.
Understanding how to implement immutability is essential for leveraging these benefits. However, to do so, we need to delve into the mechanisms that JavaScript provides for controlling object properties to enforce immutability.
Property Descriptors - The Unsung Heroes of Immutability
Property descriptors provide fine-grained control over object properties, making immutability possible. Each object property is associated with a descriptor object that defines characteristics such as writability, enumerability, and configurability.
There are two main types of property descriptors: data descriptors and accessor descriptors. A data descriptor is a property with a value that can be writable or not. An accessor descriptor uses a getter-setter pair of functions. A property descriptor must be one type or the other, but not both.
Understanding property descriptors is key to creating immutable objects. By manipulating them, we can enforce immutability and control the behaviour of object properties.
Working with Property Descriptors
The Object.getOwnPropertyDescriptor()
static method allows us to inspect the configuration of a specific property on an object. Let's take a closer look at how this method works with an example:
const series = { name: "castlevania", seasons: 4, character: "trevor" };
Object.getOwnPropertyDescriptor(series, 'name');
{
value: 'castlevania',
writable: true,
enumerable: true,
configurable: true
}
We're using Object.getOwnPropertyDescriptor()
to retrieve the data descriptor of the name
property in the series
object. The output provides details about the configuration — its value, writability, enumerability, and configurability.
Defining Properties
We can use the Object.defineProperty()
static method to define a new property on an object or modify an existing property on an object (if it’s configurable!). However, you generally wouldn’t use this manual approach unless you wanted to modify one of the descriptor characteristics from its normal behaviour.
The method accepts the target object, the target property, the descriptor object and returns the updated object.
Object.defineProperty(series, "genre", {
value: "dark fantasy",
writable: true,
enumerable: true,
configurable: true,
});
{ name: 'castlevania', seasons: 4, character: 'trevor', genre: 'dark fantasy' }
Let’s understand how each attribute affects the property.
Writable
The ability for you to change the value of a property is controlled by writable. If writable
is set to true
, the property's value can be modified; if it's false
, attempts to modify the property's value will be ignored.
Object.defineProperty(series, "genre", {
value: "dark fantasy",
writable: false,
enumerable: true,
configurable: true,
});
series.genre = 'horror'
console.log(series)
// { name: 'castlevania', seasons: 4, character: 'trevor', genre: 'dark fantasy' }
Because writable
was set to false
, any attempts to change the genre
property will fail silently. Though in strict mode, it will throw an error to tell us that we cannot change a non-writable property.
Configurable
The configurable
attribute determines whether the property's descriptor definition can be modified and whether the property can be deleted.
As long as a property is currently configurable, we can modify its descriptor definition. If this attribute is set to false
, we cannot delete the property, nor can we change other attributes in the property’s descriptor. However, if it's a data descriptor with writable: true
, the value
can be changed, and writable
can be changed to false
.
Feel free to read that again.
Object.defineProperty(series, "genre", {
value: "dark fantasy",
writable: true,
enumerable: true,
configurable: false,
});
series.genre = 'action'
console.log(series)
// { name: 'castlevania', seasons: 4, character: 'trevor', genre: 'action' }
The genre
property's descriptor has configurable
set to false
. But as you can see, we can still edit the property’s value. It is still writable.
delete series.genre
console.log(series)
// { name: "castlevania", seasons: 4, character: "trevor", genre: "action" }
Attempting to delete a non-configurable property results in silent failure. Despite trying to delete genre
, the object remains unchanged.
Finally, if we attempt to revert configurable
to true
or enumerable to false
, we encounter a TypeError
. Setting configurable
to false
is irreversible—a one-way street.
// changing configurable to true
Object.defineProperty(series, "genre", {
value: "action",
writable: true,
enumerable: true,
configurable: true,
});
// or
// changing enumerable to false
Object.defineProperty(series, "genre", {
value: "action",
writable: true,
enumerable: false,
configurable: false,
});
// both result in
TypeError: Cannot redefine property: genre
However, it’s worth noting that modifying the writeable
property is allowed. If the property descriptor is a data descriptor with writable: true
, both the value and the writable
attribute can still be modified, even when configurable
is false
. In other words, changing the writable
attribute to false
is also allowed when configurable
is false
.
Enumerable
Enumerable is just a big, scary word that controls a property’s visibility during iteration.
The enumerable
attribute controls whether a property will be included when the object’s properties are iterated through, such as in a for...in
loop. By default, all user-defined properties are enumerable, but setting enumerable
to false
hides the property during enumeration.
Object.defineProperty(series, "character", {
value: "trevor",
writable: true,
enumerable: false,
configurable: true,
});
console.log(series)
// { name: 'castlevania', seasons: 4, genre: 'action' }
const properties = Object.keys(series)
console.log(properties)
// [ 'name', 'seasons', 'genre' ]
We've set the enumerable
attribute of the character
property to false
. As a result, character
was not included in the properties
array returned by Object.keys()
, effectively hiding it during enumeration.
However, setting enumerable
to false
does not prevent access to the property. We can still access it directly.
series.character // 'trevor'
'character' in series // true
series.hasOwnProperty('character') // true
By understanding and strategically using the configurable
, writable
, and enumerable
property descriptors, we gain control over object properties in JavaScript. With careful manipulation of these descriptors, we can create predictable, immutable objects.
Creating Immutable Objects
Now that we’ve seen property descriptors in action, lets see how some of JavaScript’s built in methods leverage these to create immutable objects.
The following approaches all create shallow immutability, therefore if an object references an array, object, or function, any of those can be changed. Deep immutability is rarely needed, so if you find yourself wanting to seal or freeze all your objects, you may want to take a step back and reconsider your program design.
// let's pretend this object is immutable
myImmutableObject.foo; // [1,2,3]
myImmutableObject.foo.push( 4 );
myImmutableObject.foo; // [1,2,3,4]
Object Constant
By combining writable: false
and configurable: false
we can essentially create a constant object property. A read-only property that cannot be changed, redefined, or deleted.
Trying to edit or delete the detective
property will fail silently.
const characters = {
captain : 'Ray Holt'
};
Object.defineProperty(characters, "detective", {
value: "Jake Peralta",
writable: false,
configurable: false,
enumerable :true
});
characters.detective ='Amy Santiago';
delete characters.detective;
console.log(characters) // { captain: 'Ray Holt', detective: 'Jake Peralta' }
Prevent Extensions
An object is extensible if new properties can be added to it. Object.preventExtensions()
stops new properties from being added to an object and prevents the object's prototype from being re-assigned, keeping it as it is from that point onward.
const characters = { captain: 'Ray Holt', detective: 'Jake Peralta' }
Object.preventExtensions(characters)
characters.secretary = 'Gina'
console.log(characters) // { captain: 'Ray Holt', detective: 'Jake Peralta' }
Trying to add the secretary
property will fail silently but throw a TypeError
in strict mode. However, you can still remove properties from the object.
delete characters.captain
console.log(characters) // { detective: 'Jake Peralta' }
Seal
Sealing an object prevents extensions and makes existing properties non-configurable.
MDN says it best; A sealed object has a fixed set of properties: new properties cannot be added, existing properties cannot be removed, their enumerability and configurability cannot be changed, and its prototype cannot be re-assigned. Values of existing properties can still be changed as long as they are writable.
In other words, Object.seal(..)
takes an existing object and essentially calls Object.preventExtensions(..)
on it, but also marks all its existing properties as configurable: false
. So, not only can you not add any more properties, but you also cannot reconfigure or delete any existing properties (though you can still modify their values).
const show = { name: "Brooklyn 99" };
Object.seal(show);
show.name = "Two and a Half Men";
console.log(show.name); // 'Two and a Half Men'
// Cannot delete when sealed
delete show.name;
console.log(show.name); // 'Two and a Half Men'
Freeze
Object.freeze()
allows us to freeze an object, effectively making it immutable. Freezing an object is the highest level of immutability that JavaScript provides. It prevents extensions and makes existing properties non-writable and non-configurable.
Internally, Object.freeze()
marks all "data accessor" properties as writable: false
, making their values immutable. This approach encapsulates the object and prevents any direct or indirect alterations.
A frozen object can no longer be changed: new properties cannot be added, existing properties cannot be removed, their data descriptors cannot be changed, though, as mentioned earlier, the contents of any referenced other objects are unaffected.
const show = {
name: "arcane",
genre: "action",
};
Object.freeze(show);
show.name = "dota: dragon's blood";
console.log(show.name); // 'arcane'
delete show.genre;
console.log(show); // { name: 'arcane', genre: 'action' }
By freezing show
, we’ve created an immutable object; one that cannot be changed.
Conclusion
So while we appreciate the mutable nature of objects, we’ve learned it can lead to unintended bugs. Enforcing immutability helps maintain data integrity, improves code reliability, simplifies debugging, and promotes predictable behaviour. By leveraging property descriptors and JavaScript’s built-in methods, we create immutable objects that enhance the robustness of applications.
Immutability isn't just a concept—it's a practical approach to ensuring stable and efficient JavaScript code.
Further Reading
Here’s an article I found really helpful while doing research for this article:
- A Visual Guide to References in JavaScript: This comprehensive guide visually explains how references work in JavaScript, shedding light on crucial concepts related to mutability and immutability.
Back to Blogs