datatypes · level 8

Immutable Data

Persistent collections, structural sharing, referential equality — the win and the cost.

220 XP

Immutable Data

Immutable data is data that doesn't change once created — instead of mutating, you produce a new version with the change applied. The naive implementation costs a full copy on every update. The non-naive implementation (persistent data structures with structural sharing) costs almost nothing, scales to millions of items, and gives you constant-time change detection for free.

Analogy

Imagine a writer producing draft after draft of a novel. The naive approach is to retype the entire manuscript every time they tweak a chapter — workable for a short story, useless for War and Peace. The smart approach is to keep the unchanged chapters where they were and only retype the chapter that changed; the new "draft" is a thin index that mostly points at the old one. Anyone holding the old index still sees the old novel exactly. Two drafts, same paper for the chapters that didn't change. That's structural sharing — and it's what makes immutability practical at scale.

What "immutable" actually means

A value is immutable if you cannot modify it after creation. Operations that look like mutations actually return a new value:

const a = [1, 2, 3];
const b = a.concat([4]);   // new array; a unchanged
a;   // [1, 2, 3]
b;   // [1, 2, 3, 4]
a === b;   // false — different objects

concat always returns a new array. push mutates in place. The shape of the API matters: immutable APIs use concat, set, update, with. Mutating APIs use push, set (on Map!), splice, delete.

In TypeScript, readonly and Readonly<T> make mutation a compile-time error:

function rename(user: Readonly<User>): User {
  // user.name = "Lin";   // TS error: cannot assign to readonly property
  return { ...user, name: "Lin" };
}

Structural sharing

The naive way to "make immutable updates cheap" is to copy. That's O(n) every time and produces unfriendly garbage pressure. Persistent data structures share the unchanged parts.

For a 32-way hash array-mapped trie (HAMT, used by Immutable.js, Clojure, Scala), inserting one key into a 1-million-entry map:

  • Copies roughly log32(1,000,000) ≈ 4 nodes.
  • Shares the rest pointer-equal with the previous version.
  • Returns a new map in O(log n) time, in practice constant for any realistic n.
old map:
                ┌──root──┐
              A B C D E F  G
              │ │ │   │ │  │
             a1 b1 c1 d1 e1 f1 g1


new map after .set('e', e2):
                ┌──root'──┐
              A B C D E' F  G
              │ │ │   │  │  │
             a1 b1 c1 d1 e2 f1 g1
                  ↑shared with old map (same pointers)

The leaves that didn't change in the path to e are reused. Only the modified path is fresh. oldMap === newMap is false (different roots), but oldMap.get('a') === newMap.get('a') is true — same node.

Referential equality is the win

Why React, Redux, and the broader functional-data ecosystem care so much: with immutable updates, you can detect change in O(1) — by reference comparison.

// React.memo skips render when props are pointer-equal to the previous ones.
const UserCard = React.memo(function UserCard({ user }: { user: User }) {
  return <div>{user.name}</div>;
});

// In the parent:
const [users, setUsers] = useState(initialUsers);

// If you mutate (BAD):
users[0].name = "Lin";
setUsers(users);   // useState bails — same array reference, no re-render

// If you replace immutably (GOOD):
setUsers(users.map((u, i) => i === 0 ? { ...u, name: "Lin" } : u));
// new array, only index 0 has a new object → only that card re-renders

Without immutability, "did this change?" is a deep walk of the data structure. With immutability, it's ===.

The cost: allocations

Every "mutation" allocates. For most apps this is fine — modern allocators are fast and most updates are small. For high-throughput hot paths (game loops, real-time signal processing, intensive numerics), the allocation pressure can hurt. The escape valves:

  • Mutate inside a small scope, then freeze. Build the result with imperative code, return it as immutable to the rest of the system.
  • Use mutable buffers with a .toImmutable() step. Immutable.js has withMutations; pyrsistent has evolver.
  • Use Immer. Records the mutations to a draft and applies them to produce a new persistent value at the end.
import { produce } from "immer";

const next = produce(state, (draft) => {
  for (const order of newOrders) {
    draft.byId[order.id] = order;
    draft.allIds.push(order.id);
  }
});
// next is a new persistent object; state untouched.

Immer's authors call this "the best of both worlds" — write mutating code for ergonomics, get persistent semantics for correctness.

Object.freeze is shallow

JavaScript ships a built-in Object.freeze that prevents adding, removing, or modifying properties:

const obj = Object.freeze({ name: "Ada", tags: ["new"] });

obj.name = "Lin";        // silently ignored (strict: throws)
obj.tags.push("vip");    // ✓ works! tags is not frozen

Object.freeze is shallow. Nested objects are still mutable. To enforce deep immutability you write a deepFreeze function, switch to a persistent collection library, or rely on TypeScript's Readonly types and trust the build.

function deepFreeze<T extends object>(o: T): T {
  for (const v of Object.values(o)) {
    if (v && typeof v === "object") deepFreeze(v);
  }
  return Object.freeze(o);
}

Persistent collections in different stacks

Stack Built-in Library
JavaScript spread + Object.freeze (shallow) Immutable.js, Immer, mori
TypeScript Readonly<T>, as const same as above
Python tuple, frozenset, @dataclass(frozen=True) pyrsistent, immutables
Java List.of, Map.of, records Vavr, Functional Java
Scala immutable collections by default built-in (Vector, Map)
Clojure persistent collections by default built-in
Rust Arc<T> for shared, ownership-based im, rpds crates

Clojure and Scala default to immutable collections; you have to opt into mutation. JavaScript and Python are the opposite. The defaults shape what your team will write.

Copy-on-write at the OS level

The same idea shows up in operating systems. fork() doesn't copy a process's pages — it marks them read-only and shared. The first time either parent or child writes, the OS then copies the page. Same trick: structural sharing at the page level.

Container layers, filesystem snapshots (ZFS, btrfs), copy-on-write file copies (APFS) all use this pattern. Immutable data structures are the application-level version of the same idea.

Common bugs

Mutating "immutable" data. Adding to a frozen array, mutating a deep nested object the React linter doesn't catch. The bug looks like "state didn't update" because referential equality reports no change.

Spread-shallow. { ...user } copies top-level fields but shares nested ones:

const copy = { ...user };
copy.tags.push("vip");   // mutates user.tags too!
// fix: { ...user, tags: [...user.tags, "vip"] }

useState(initialValue) with mutable initial. Mutating the initial value in development re-runs the function with a different starting state. Symptom: works in tests, breaks in dev StrictMode.

Performance assumptions wrong both directions. Sometimes immutable is faster (referential equality cuts the work). Sometimes it's slower (allocation churn). Profile before optimising.

Trying to make every value immutable. Diminishing returns — strings and numbers are already value types. The win is on collections (lists, maps, sets) and structured records.

Assuming === does a deep check. It doesn't. [1] === [1] is false. A new immutable update with the same logical value is still a new object.

Practical decisions

  • For React + Redux state: immutable everywhere. Use Immer or Redux Toolkit (which uses Immer under the hood).
  • For UI components: prefer Readonly<Props> to clarify intent.
  • For data that holds for a request and dies: don't worry about it.
  • For data that lives across versions / undo / replay: persistent collections pay for themselves.
  • For numeric / hot-loop code: mutable arrays with controlled lifetime; freeze at the boundary.
  • For language defaults that fight you (JS, Python): pick a strict-readonly lint rule and enforce it.