skip to content

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

level: middleimportance: must knowfreq 60%

answer

  1. a closure property, not a field property
  2. follow every outgoing reference
  3. elements, and elements of elements
  4. retained aliases from construction count
  5. observable change is the real test

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.

solid answer

~50 s

Take the price list, follow every outgoing reference, follow the references out of those objects, and keep going until nothing new appears; that set is the value's **reachable closure**. It is deeply immutable when no object in that closure can be written by any holder of any reference for the rest of its life. Two words carry the weight. *Every*, because one writable object anywhere in the closure falsifies the claim — it is a universal. *Anyone*, because it is not enough that this type offers no way to write: a container handed in at construction and still held by the caller is inside the closure and outside your control. So the check has to include containers, the elements inside them, the elements inside those, and the provenance of anything the value did not build itself.

code

pseudocode · 7 lines
pseudocode
function publish(rows)
    value = { tiers: rows }   // stores the caller's container as-is
    fixOwnFields(value)       // fixes value's bindings only
    return value

published = publish(callerRows)
callerRows.append(extraRow)   // reachable from published, and still writable

go deeper

for a junior

Learn the shape of the walk: start at the value, follow each reference, then follow the references you find, and keep going. The claim covers everything that walk reaches, not just the first hop.

for a middle

Explain the closure precisely and name the two separate container questions: can membership change, and can an element be written. Then add provenance, because an alias kept by the caller is inside the closure too.

for a senior

Demonstrate that you check this by reference escape, not by declaration: which objects came from outside, who kept a handle, and what the value promises to readers that depends on the closure holding.

for a principal

Set the bar for what earns the label across teams, including how far the observable-state relaxation may be stretched, so that remembered members do not quietly become an exception nobody wrote down.

Shallow immutability is a property of a field. Deep, or transitive, immutability is a property of a **set of objects**, and that difference is what the question is really asking about. ## The condition, stated precisely Take the published price list. Follow every reference out of it — the container of tier rows, a currency descriptor, an effective-date object. Follow every reference out of each of those. Keep going until the walk produces nothing new. The set you have enumerated is the value's **reachable closure**. The price list is deeply immutable when **no object in that closure can be written by anyone, through any reference, for the remainder of its life**. Two words in that sentence do all the work: - **Every.** The claim is a universal, so a single writable object anywhere in the closure falsifies it. There is no partial credit and no majority. - **Anyone.** The test is not whether *this* type offers a way to write. It is whether any holder of any reference into the closure can write. That is a question about the whole program, not about one declaration. ## The paths people forget 1. **Elements inside a container.** Fixing membership does not fix the elements; those are separate objects with their own state. 2. **Elements inside those elements.** A tier row that holds a container of regional overrides pushes the walk one level deeper, and the walk has to go there. 3. **State captured by a stored function.** A value that holds a callable holds whatever that callable kept a reference to; those objects are in the closure too. 4. **Anything handed in at construction.** The caller passed it; the caller may still have it. 5. **Anything a member builds lazily and then exposes.** If a read can hand out a writable object, that object is reachable by definition. ## Aliases are part of the condition The condition is about the object graph as it exists at run time, not about the code you wrote. A builder that receives a container of rows, stores it unchanged and then fixes its own fields has changed nothing about the caller's reference. Your type looks disciplined and the closure still contains an object somebody else can write. This is why deep immutability cannot be certified by reading one type in isolation: you also have to know how the objects inside it were obtained, and whether any reference to them escaped. ## A type without writes is not an unchangeable value A type that simply offers no write operations constrains **one route** to the data. If the data underneath is a live structure that something else still holds, the closure contains a writable object and the condition fails regardless. What that type establishes is real and worth having — this path offers no writes — but it is a different claim from *nothing reachable can change*. ## Observable rather than physical unchangeability Strictly, the condition above talks about physical writes. In practice teams apply it to **observable** state. A member computed on first read and remembered afterwards does write to memory, yet no sequence of reads can tell it apart from one that recomputes every time — *provided* the computed result is derived only from unchangeable state and comes out identical every time. Most designs accept that as immutable, deliberately. It is a relaxation with conditions attached, not a counterexample, and if the remembered value could differ between two computations the relaxation simply does not apply. ## Checking it in practice | What you are checking | The question to ask | A yes that still fails | |---|---|---| | The root | Can any of its own fields be written? | Fields fixed, referenced objects writable | | A container field | Can membership change? | Membership fixed, elements writable | | An element | Can its own state be written? | This element fixed, its children writable | | Provenance | Who else holds this object? | Nobody now, but a reference escaped earlier | The procedure is mechanical: list the fields, classify each referenced type, ask the two separate container questions rather than one, ask who else holds anything you did not construct yourself, then recurse. What makes this a mid-level question rather than a junior one is not the definition — it is knowing that the recursion and the provenance check are both part of it, and that stopping one hop from the root is the error nearly everyone makes.

  • Does wrapping a live container in a type with no write operations satisfy the condition?
    No. The wrapper removes writes from one path to the data; it does not remove the container underneath, and whoever holds that container can still change what the wrapper reports. The condition asks what can change, not which operations a particular type offers. It is a useful narrowing of one route, not a guarantee about the closure.
  • Is a member computed on first read and then remembered a violation?
    Physically yes, observably no, and the observable reading is the one teams use. It counts as immutable only while the remembered result is derived purely from unchangeable state and is identical on every computation. If either condition slips — a clock, a counter, an outside lookup — the write becomes observable and the relaxation stops applying.
  • How do you check the condition for a value whose parts came from three different subsystems?
    By provenance rather than by declaration. For each part, ask who handed it over, whether they kept a reference, and whether the part's type can be written at all. Parts you cannot answer for are the boundary of the claim, and the honest move is to admit data across that boundary in a form you do control.

saying these in an interview costs you the question

  • Checking only the value's own fields and stopping there
  • Forgetting that the builder's caller may still hold the same container
  • Treating a type with no write operations as proof nothing can change
  • Assuming a container that refuses edits makes its elements unwritable
  • Confusing nothing has changed yet with nothing can change