Advertisement

Mutability sounds like abstract language-design trivia until it bites you: you change one variable and something else, seemingly unrelated, changes too. A whole family of baffling bugs traces back to not understanding whether a piece of data can be modified in place, and who else is holding a reference to it.

Getting this straight prevents a surprising amount of pain.

The core idea

Mutable data can be changed after it is created; immutable data cannot — any "change" produces a new value instead. When you pass mutable data around, multiple parts of your program may hold references to the very same object, so a modification in one place is visible everywhere. Immutable data, by contrast, cannot surprise you this way, because it never changes underneath anyone.

This is why languages and libraries increasingly favour immutability for shared state: it removes an entire category of "spooky action at a distance".

Advertisement

How it bites

The classic bug: you copy a list or object by assigning it to a new variable, assume you now have an independent copy, and then modifying the "copy" also mutates the original — because both names point at the same mutable thing. Shared mutable state across functions, threads or components produces some of the hardest bugs to reproduce, since the culprit is often far from the symptom.

The confusion is almost always "I changed A, why did B change?" — and the answer is that A and B were the same object all along.

Working with it safely

A few habits help: prefer immutable values for anything shared, be deliberate about when you truly want a copy versus a reference, and know your language’s rules for which types are mutable. When you need to modify shared data, make the sharing and the mutation explicit rather than accidental.

You do not have to make everything immutable. You do have to know which is which — because the bugs come from assuming, not from choosing.

Advertisement

How JavaScript actually enforces (and fails to enforce) immutability

`const` in JavaScript is a frequent source of confusion specifically because it does not do what its name suggests for objects and arrays: it prevents reassigning the variable itself to a different value, but it does nothing whatsoever to prevent mutating the contents of the object or array that variable already points to — `const arr = [1, 2, 3]; arr.push(4)` is perfectly legal, because `arr` still refers to the same array, only its contents changed. `Object.freeze()` goes one level further, genuinely preventing property reassignment on the frozen object, but it is shallow by default: freezing an object with a nested object inside it leaves that nested object fully mutable, which is a common and consequential surprise for anyone assuming `Object.freeze()` means 'nothing about this can ever change.'

Persistent data structures: immutability without copying everything

A naive implementation of 'immutable' data might copy an entire array or object on every single change, which becomes prohibitively expensive for large collections changed frequently — persistent data structures, the kind used by libraries like Immutable.js, solve this with structural sharing: a change produces a new top-level structure that shares as much of its internal structure as possible with the original, unchanged parts, typically implemented as a tree where only the path from the root to the changed leaf needs to be rebuilt, while every other branch is reused directly rather than copied. This is what makes genuinely immutable data practical at scale, rather than a theoretically nice idea that is too slow to use for anything beyond small, rarely-changed values.

Advertisement

Rust: making the distinction a compiler-enforced rule rather than a convention

Where JavaScript treats mutability as a loose convention that `const` only partially enforces, Rust makes it a first-class, compiler-checked property of every binding: variables are immutable by default and must be explicitly marked `mut` to allow mutation at all, and Rust's ownership and borrowing rules go further still, ensuring at compile time that mutable access to a piece of data is never held at the same time as any other reference to it — eliminating an entire category of bugs, a mutation racing against a read, that dynamically typed languages can only catch at runtime, if they catch them at all.

Why immutability matters more once concurrency enters the picture

The practical value of immutable data becomes sharpest in concurrent or parallel code: a genuinely immutable value can be freely shared between multiple threads with zero risk of a data race, since there is no mutation for two threads to ever conflict over, which is precisely why functional languages and functional-programming-influenced patterns in mainstream languages lean so heavily on immutability specifically for concurrent workloads — it removes an entire class of synchronization problem by construction, rather than requiring locks or other explicit coordination mechanisms to prevent it after the fact.

Copy-on-write: a middle ground between always-copy and always-mutate

Copy-on-write is a practical compromise several languages and systems use to get most of immutability's safety without paying its full copying cost upfront: a value is shared freely between multiple owners as though it were immutable, and an actual copy is only made lazily, at the specific moment one of those owners tries to mutate it, leaving every other owner's view of the original untouched. Python's string interning and certain string implementations across other languages use variations of this idea, and recognizing it as a distinct middle-ground strategy — rather than assuming a language is either strictly mutable or strictly immutable throughout — explains some otherwise puzzling performance characteristics where a mutation appears to cost more than a mere in-place change would suggest.

Records and value types: newer mainstream languages formalizing the distinction

Several mainstream languages have added dedicated syntax specifically for immutable-by-default data in recent years — C# records, Java records, Kotlin data classes — precisely because retrofitting immutability onto an ordinary mutable class requires writing a considerable amount of boilerplate (a constructor, equality, and a hash implementation all consistent with treating the object as a value rather than an identity) by hand, and the dedicated syntax exists specifically to make the immutable, value-semantics version no more work to write than the mutable default would have been, removing the main practical excuse for reaching for mutable state as the path of least resistance.

Why 'immutable' does not mean 'the underlying memory never changes'

It is worth separating the language-level guarantee from the hardware reality underneath it: even a genuinely immutable value, once created, may still involve memory being written and read by the CPU during garbage collection, memory compaction, or other runtime housekeeping that has nothing to do with the value's logical content ever changing. The immutability guarantee a language or library offers is a promise about observable behavior — no code can cause this value's logical content to change after creation — not a claim about what happens at the level of physical memory cells, which is a distinction worth keeping clear when reasoning about performance rather than correctness.

Const-correctness in C++: a third model, checked but not fully immutable by default

C++ occupies a middle position worth naming alongside JavaScript's loose convention and Rust's compiler-enforced default: `const` on a variable or pointer is checked at compile time and genuinely prevents mutation through that specific reference, but unlike Rust, mutability is still the default absent an explicit `const`, and `const_cast` exists as an explicit, deliberately visible escape hatch to strip constness away when truly necessary — a design that trades some of Rust's stronger guarantees for closer compatibility with C's original, fully mutable-by-default model.

Why 'never mutate' is a starting default, not an absolute rule

None of this is an argument that mutation is always wrong; a tight, performance-critical loop processing a large in-place buffer often genuinely benefits from direct mutation, avoiding the allocation cost immutable updates would otherwise impose on every iteration. The practical guidance mature codebases converge on is treating immutability as the default choice, reached for automatically, and mutation as the deliberate, narrow exception reached for specifically where its performance benefit is measured and real — rather than either extreme of always mutating out of habit or never mutating out of dogma regardless of the actual workload's needs.

Enum-like frozen objects: a common JavaScript pattern worth naming

A frequent, practical use of `Object.freeze()` in JavaScript is simulating an enum — a fixed set of named constant values — since the language has no dedicated enum syntax of its own; freezing a plain object mapping names to values (`Object.freeze({ PENDING: 'pending', DONE: 'done' })`) at least prevents the specific mistake of accidentally reassigning one of those constant values elsewhere in a large codebase, even though, as covered earlier, the freeze remains shallow and offers no protection at all against a nested structure inside one of those values being mutated instead.

Swift and value types: immutability as part of the type system's vocabulary

Swift makes the mutable/immutable distinction part of its type vocabulary directly through value types (structs) versus reference types (classes): assigning or passing a struct copies its value, so mutating the copy never affects the original, while classes retain the familiar shared-reference behavior discussed throughout this pair of articles — which means the choice between a struct and a class in Swift is, in large part, a direct choice about exactly the mutation-and-sharing trade-off this whole subject is about, made explicit at the point a type is declared rather than left to convention.

Deep freezing: closing the shallow-freeze gap by hand

A recursive deep-freeze helper — freezing an object, then walking every property that is itself an object or array and freezing each of those in turn, all the way down — is the direct fix for the shallow-freeze limitation discussed earlier, and several small utility libraries exist purely to provide it, since writing it correctly by hand means remembering to handle arrays, nested objects, and circular references all at once, which is easy to get subtly wrong on a first attempt.

Why immutability makes undo/redo almost trivial to implement

An undo feature built on mutable state has to explicitly record what changed at every step so it can be reversed later, while an undo feature built on immutable state can simply keep a list of previous whole-state snapshots and step backward through it directly, since each snapshot is guaranteed never to have changed after it was captured — which is exactly why editors and state-management libraries that support undo lean so heavily on immutable data structures rather than mutating a single shared state object in place.

Why a hash map key needs an immutable value underneath it

Using a mutable object as a key in a hash-based collection is a well-known trap across many languages, because the object's hash code is typically computed from its contents at the moment it is inserted, and mutating that same object afterward can leave it hashed to the wrong bucket relative to its now-changed contents, making it unfindable through the very key that was used to insert it — which is exactly why languages that allow arbitrary objects as hash keys generally recommend, and some strictly require, that the key's relevant fields never change after insertion.