Immutability
Building programs out of values that never change — and why this makes large systems easier, not harder.
A value is immutable if, once created, it cannot be changed. You can build new values from it, but the original stays as it was, forever. In functional programming, this is the default. Mutation is the exception, not the rule.
Immutability is not about asceticism. It is about reasoning.
The cost of mutation, in one example
Consider a simple function that "increments a user's age" and a caller that uses the user afterward.
Reading the function birthday, you might assume it returns a new
user. It doesn't — it mutates the one you gave it. Every caller
who held a reference to alice is now silently affected.
This is the entire problem with mutation: it leaks across
boundaries that are invisible in the code. The caller of
birthday cannot tell from its signature that anything has changed.
The immutable version is unambiguous:
The change is explicit: a new value is returned, the old one is
unchanged, and the type system told you about it (birthday: (u: User) => User).
readonly is your default
TypeScript gives you several ways to mark immutability. Use them liberally.
A useful policy: start everything as readonly and remove the
modifier only when you have a specific reason. Most fields never
need to change.
Building new values: spread, map, filter, reduce
Once you commit to immutability, you stop changing values and start producing new ones. The vocabulary is small.
Updating a record
{ ...x, k: v } is the canonical "update a field" idiom. It builds
a brand-new object that shares everything with x except k.
Updating a nested record
This nesting gets tedious for very deep updates. We will see in later chapters how lenses (a pure functional pattern) make this ergonomic, and how flatter data designs avoid the problem altogether.
Updating a list
Notice: every operation returns a new array. The original todos
is forever the original todos.
Performance: isn't this wasteful?
A common worry: "if every update copies, isn't immutability slow?"
The honest answer is: it can be, naively, but it usually isn't for two reasons.
- Structural sharing. When you spread an object, the new object shares all the inner values with the old one. You don't deep-copy. For a 20-field record, an update copies 20 pointers, not 20 payloads.
- The bottleneck is usually elsewhere. Most programs spend their time on I/O, rendering, JSON parsing, or framework overhead — not on object allocation. The allocations from immutability are typically rounding error compared to a network call or a React render.
For the rare cases where allocation is the bottleneck (huge data structures, hot inner loops), persistent data structure libraries like Immer (using copy-on-write proxies) or Immutable.js (using hash array mapped tries) give you immutability with O(log n) updates. But you almost never need them.
Immutability + parallelism = free
Two pieces of code that share no mutable state cannot race. Period. They might run in any order, on any thread, on any machine, and the result is the same.
Three tasks reading the same immutable v1 can all run in parallel
with no locks, no atomics, no mutexes, no worry. That's the
property that lets Array.prototype.map be safely parallelizable in
languages that do so.
A practical example: an undo stack
Immutability makes whole categories of feature trivially easy. Consider an "undo" feature.
This implementation is ~20 lines and correct. In an imperative world, undo means snapshotting state, deep-cloning it, tracking diffs, or wiring elaborate command-pattern machinery. With immutability, the "snapshot" is the old value itself — you couldn't have changed it if you tried.
Mutation, hidden in plain sight
A few sneaky ways mutation gets in even when you're "trying":
The standard library is full of these traps. As a rule:
Array#sort,Array#reverse,Array#splice,Array#push,Array#pop,Array#shift,Array#unshift— mutateArray#map,Array#filter,Array#slice,Array#concat,Array#flatMap,Array#toSorted,Array#toReversed,Array#toSpliced,Array#with— return new
When in doubt, prefer the new immutable variants (toSorted,
toReversed, etc.) introduced in ES2023.
A multi-file challenge
Implement three pure functions in cart.ts that all
return new carts and never mutate the input.
add(cart, item)— appenditem.remove(cart, itemId)— remove the entry whoseidmatches.setQty(cart, itemId, qty)— update the matching entry's quantity.
If itemId is not found in remove or setQty, return the cart
unchanged.
Expected output
total items: 3
after remove: 2
after setQty: 5
original unchanged: 3
What is the most important practical benefit of immutability in a large codebase?
It always makes programs faster
It removes the need for unit tests
You can pass a value across modules and be certain nobody can change it behind your back
It makes == always work correctly