Dataslope logoDataslope

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.

Code Block
TypeScript 5.7

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:

Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

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

Code Block
TypeScript 5.7

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

Code Block
TypeScript 5.7

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

Code Block
TypeScript 5.7

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.

  1. 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.
  2. 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.

Code Block
TypeScript 5.7

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":

Code Block
TypeScript 5.7
Code Block
TypeScript 5.7

The standard library is full of these traps. As a rule:

  • Array#sort, Array#reverse, Array#splice, Array#push, Array#pop, Array#shift, Array#unshiftmutate
  • Array#map, Array#filter, Array#slice, Array#concat, Array#flatMap, Array#toSorted, Array#toReversed, Array#toSpliced, Array#withreturn new

When in doubt, prefer the new immutable variants (toSorted, toReversed, etc.) introduced in ES2023.

A multi-file challenge

Challenge
TypeScript 5.7
Immutable cart operations

Implement three pure functions in cart.ts that all return new carts and never mutate the input.

  • add(cart, item) — append item.
  • remove(cart, itemId) — remove the entry whose id matches.
  • 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

QuestionSelect one

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

On this page