skip to content

Shallow vs Deep Immutability

Why a value that cannot be reassigned can still change underneath you, and what transitive immutability actually demands. Interviewers pick this because it is where most candidates' answers collapse.

on this pageshow

questions

4

A price list's tier-row field is never reassigned, yet a reader sees a row's amount change — why?

level: juniorimportance: must knowfreq 72%

answer

  1. the binding, not the thing bound
  2. one layer only
  3. the rows are separate objects
  4. any surviving reference can still write
  5. transitive means every reachable object

basics

~20 s

Fixing a field fixes the binding, not the object it points at. The tier rows are separate objects; anyone holding a reference to one can write its amount in place, without ever touching the price list's field.

solid answer

~50 s

A field that can never be rebound guarantees exactly one thing: it keeps pointing at the same object for the value's whole life. It says nothing about that object's own state. A price list holds a reference to a container of tier rows, and each row is a further object with a writable amount, so a write through any surviving reference — one the builder kept, one an accessor handed out, one another index shares — changes what a reader of the price list observes while the frozen field stays exactly as it was. That is **shallow immutability**: the top layer is fixed, everything beneath it is ordinary mutable data. **Deep**, or transitive, immutability is the stronger claim that nothing reachable from the value can change for anyone, and only that one lets a reader treat two reads a minute apart as the same value.

code

pseudocode · 6 lines
pseudocode
priceList = { tiers: tierRows }   // the tiers field is never rebound

row = priceList.tiers[0]
row.amount = 99                   // writes a row, not a field of priceList

read(priceList.tiers[0].amount)   // -> 99

go deeper

for a junior

Be able to say the sentence out loud: fixing a field settles which object the field points at, and nothing more. Then name the second object, the row, as the thing that actually changed.

for a middle

Explain how the write got in. Some reference to the rows outlived construction, and a write through it never touches the root's own field. Walk the reference path from root to row when asked.

for a senior

Show where this bites in a running system: a value shared between workers or cached as though stable, then observed to differ between two reads with no reassignment anywhere in the history.

for a principal

Decide what the word immutable is allowed to mean as a house rule, and what a team must demonstrate before a type may carry the label. Without that, every consumer invents its own defences.

The word *immutable* is doing two different jobs in this question, and separating them is the whole answer. ## The value is a graph, not an object A price list is not one object. It is a root object holding a reference to a container of tier rows, and each tier row is a further object holding a threshold and an amount. Freezing happens at a particular place in that graph, and the place matters more than the word does. When someone says *the price list is immutable*, the honest follow-up is: immutable at which layer? ## What a fixed binding promises Most languages offer some way to say that a field, once initialised, may never be given another value; runtimes differ in how far they let you take it, but the common core is the same everywhere. It fixes the **binding** — the arrow from the field to an object. After construction, the price list's `tiers` field points at the same container for as long as the price list exists. That is the entire promise. It says nothing about the container at the far end of the arrow, and nothing about the rows inside the container. A write that lands on a row changes what a reader of the price list sees while leaving the frozen field untouched: the arrow still points where it always pointed, and the thing it points at is different inside. This is **shallow immutability** — one layer fixed, everything beneath it ordinary. ## Where the write actually gets in Every route has the same signature: none of them writes a field the freeze covers. 1. **An alias kept from construction.** Whoever built the price list passed in a container and may still hold a reference to it. 2. **A reference handed out later.** An accessor that returns the internal container gives every caller a writable handle to it. 3. **A row reachable by a second path.** The same row object may also sit in an index, a draft or an audit record, and a write there is a write here. 4. **The owner's own code.** Code inside the type that still treats the container as its working storage after publication. ## Tracing it ``` priceList = { tiers: tierRows } // the tiers field is never rebound row = priceList.tiers[0] row.amount = 99 // a row is written; no field of priceList is read(priceList.tiers[0].amount) // -> 99 ``` Two reads of the same never-rebound field, a minute apart, disagree — and nothing in that trace violated the freeze. ## Shallow against deep, side by side | Property | Fixed root field | Deep (transitive) immutability | |---|---|---| | The field keeps pointing at one container | yes | yes | | The root's own fields cannot be written | yes | yes | | Rows cannot be added or removed | no | yes | | An existing row's amount cannot change | no | yes | | Nothing reachable can change for anyone | no | yes | ## Why the trap catches good candidates Because the mental model is nearly right. A candidate who says *a fixed field means the value cannot change* has built a model that holds perfectly for a value whose parts are all scalars — two numbers, a piece of text in a language where text is unchangeable. The model only fails when a field refers to something that has writable state of its own, which is exactly what a container of rows is. The interviewer is not testing whether you know a keyword; they are testing whether your model has layers in it. ## What the distinction is worth Readers want immutability for its consequences: two reads agree, so a derived total can be cached; the value can be handed to another part of the system and treated as stable; a check made at construction still holds when somebody reads it an hour later. Deep immutability is what pays those out. A fixed root field pays exactly one of them — you will always find the same container there — and on its own that is rarely the property anyone wanted. Repairing the situation is a separate subject: it is a question of where the value's boundary sits and what is allowed across it, not of adding another freeze at the root. ## Saying it well The strongest version of this answer names the layer. A weak answer says *the object was modified*; a strong one says **which** object was modified and points out that it is not the object whose field was fixed. Adding that the guarantee has to hold for the whole reachable set, not just the root, gives the interviewer the follow-up they were already reaching for.

  • If every tier row were itself unwritable, would freezing the root field be enough?
    Only if the container also refuses insertion, removal and replacement. Unwritable rows in a container whose slots can be overwritten still let a reader see a different amount at position zero. Once the container's membership is fixed and its elements cannot be written, one fixed root field genuinely is enough, and the shallow-versus-deep distinction stops mattering for that value.
  • Two reads of the price list a minute apart return equal-looking values. What has that proved?
    That nobody happened to write in that minute. It is evidence about one interval, not a guarantee. Immutability is a claim about what can happen, so it is established by knowing who can reach the rows and what they may do with them, never by sampling reads and finding them equal.

Bolting a picture frame to the wall settles which print hangs in it, not what the print shows. Anyone who can still paint on that print changes the picture without ever touching the bolt.

saying these in an interview costs you the question

  • Claiming a field that cannot be rebound makes the whole value unchangeable
  • Treating the reference cannot change and the value cannot change as one claim
  • Believing a container that rejects insertion also protects its elements
  • Assuming the change must have come from a reassignment somewhere
  • Taking for granted that nobody else kept a reference to the rows
open as a page

For a published price list to be deeply immutable, what must be true of every object reachable from it?

level: middleimportance: must knowfreq 60%

basics

~20 s

Deep immutability is a closure property: follow every reference out of the price list, and out of those objects in turn, and each object found must be unwritable by anyone at all — including whoever built the value and still holds a reference into it.

open as a page

A price-list type documented as immutable returns its live tier-row container from an accessor — which promises to callers are now false?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Three promises collapse at once: that a check made at construction still holds at read time, that two reads agree so a derived result can be cached, and that the value can be passed around and treated as stable. One writable path falsifies all of them.

open as a page

What does deep-freezing every object reachable from a published price list cost as that graph grows?

level: seniorimportance: should knowfreq 38%

basics

~20 s

It costs a full walk of the reachable graph on every publication: work proportional to the number of reachable objects, not to the size of the edit. Parts nothing touched are re-walked, and some objects cannot be made unwritable from outside at all.

open as a page