Creating Immutable Objects in JavaScript

MoniqueMonique
13 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.

MDN

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.)

Assigning the age variable

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.

Reassigning the age variable

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 characterlocation 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 seriesobject. 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 configurableis 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 falsehides 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:

0
Subscribe to my newsletter

Read articles from Monique directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Monique
Monique

hi! i'm Monique, a novice writer, and i write blogs for me and developers like me — beginners to coding who want to dive a little deeper and know exactly why code behaves the way it does.