Teams call a type "immutable" when they may mean one of three different things: that the name cannot be rebound, that the object's own fields cannot be written, or that nothing reachable from the object can change for anyone. Separate those three guarantees, explain why a read-only interface type — such as Kotlin's List, which a MutableList can be upcast to without a copy — delivers none of the third, and say what a language has to take away from the programmer in order to guarantee it.
answer
- Binding vs shallow vs transitive — three different promises
- Read-only type = restricted view, not a property of the value
- Kotlin List upcast from MutableList: free, no copy, no guarantee
- C# IReadOnlyList / TS ReadonlyArray: same hole, TS erased at runtime
- Transitivity is paid for in data shape: Rc/Weak, arena indices, named interior mutability
basics
~20 sBinding immutability fixes the name; shallow fixes the object's own fields; transitive means nothing reachable can change for anyone. Only transitive enables safe sharing. A read-only interface is just a view — someone may still hold the mutable original.
solid answer
~60 sThree separate guarantees hide behind one word. - **Binding**: the name cannot point elsewhere. Java's `final`, Kotlin's `val`, JavaScript's `const`. Says nothing about the object. - **Shallow**: the object's own fields cannot be written. A record or frozen dataclass — but a field holding a mutable collection is still open. - **Transitive**: nothing reachable, through any path, by any holder, can change. Only this supports the claims people make for immutability: share across threads without locks, hand out from a getter without copying, use as a map key, memoize. A **read-only type is not immutability, it is a restricted view**. Kotlin's `List` is an interface `MutableList` implements; upcast and the original holder still mutates what you see. C#'s `IReadOnlyList<T>` and TypeScript's `ReadonlyArray<T>` have exactly the same hole (TypeScript's vanishes entirely at runtime). Buying transitivity costs data shape. Rust gets it by banning aliasing plus mutation, so back-pointers and shared graphs need `Rc`/`Weak`, arena indices, or a type that *names* the escape (`Cell`, `RefCell`, `Mutex`). Clojure gets it by shipping persistent structures and mostly not offering mutation. Swift gets it for struct trees, and loses it the moment a class is embedded.
code
text · 7 linesbacking = mutable_list("a")
view: ReadOnlyList = backing // upcast: same object, no copy
backing.add("b")
print(view.size) // 2 — the "read-only" handle changed
// the binding `view` was fixed; the contents were never frozengo deeper
Name the three levels and give one concrete example of the trap: a fixed variable pointing at a list whose contents still change. Knowing that read-only and immutable are different words is the core recall.
Show the upcast: a mutable structure handed out under a read-only type, same object, no copy, so the holder of the original still mutates what the callee reads. Then state which guarantees (thread sharing, map keys, no defensive copy) actually require the transitive form.
Frame it as a review obligation: walk every field type and ask whether the constructor received a reference someone else retained. Discuss snapshot-at-the-boundary versus wrapper, and add safe publication as a separate requirement from immutability.
Argue about what a language buys and pays. Transitivity is enforceable only by removing aliasing-plus-mutation or by removing mutation outright, and both push cost into data shape — reference counting with weak edges, arenas, or interior mutability named in types. Decide per codebase whether the invariant is worth the modeling tax, and how the boundary between the immutable core and the mutable edge is drawn.
## One word, three guarantees Almost every disagreement about immutability is really a disagreement about which of three guarantees is meant. **Binding immutability** — the *name* cannot be rebound to a different object. This is Java's `final` on a local or field, Kotlin's `val`, JavaScript's `const`, C#'s `readonly`. It constrains the variable, not the thing. **Shallow (object) immutability** — the object's *own fields* cannot be written after construction. A Java record, a Kotlin data class with only `val`s, a Python `@dataclass(frozen=True)`, a tuple. Each field slot is sealed; whatever the slot points at is not. **Transitive (deep) immutability** — nothing reachable from the object can be changed, through any path, by any holder, ever. This is the only version that is a property of the *value* rather than of one access path. The distinction matters because every valuable consequence people cite requires the third form. Sharing a value across threads without synchronization requires that no other holder can write to anything you can read. Handing an internal collection out of an accessor without copying requires that the caller cannot write and neither can anyone else. Using an object as a hash-map key requires the fields consulted for hashing to be stable for the lifetime of the entry. Memoization and structural sharing assume nothing reachable moves. Binding and shallow immutability deliver none of these on their own. ## A read-only type is a view, not a property The most common way to mistake shallow for transitive is to trust a read-only *type*. Kotlin is the clearest case, and the language is explicit that it is not a guarantee: `List` is a read-only **interface**, `MutableList` extends it, and upcasting is free — same object, no copy. If the producer keeps its `MutableList` reference and hands you the `List` handle, everything you read through your handle can change underneath you, mid-iteration included. The `val` on your variable froze the binding; the `List` type froze nothing. Kotlin's actual answer for the strong guarantee is a separate library of persistent collections, not the read-only interface. The same shape appears elsewhere with different names. C#'s `IReadOnlyList<T>` is implemented by `List<T>`, so it is a view over a list somebody still owns; `ImmutableList<T>` is the different, real thing. TypeScript's `ReadonlyArray<T>` and `Readonly<T>` are stricter still in one sense — they are checked by the compiler — and weaker in another: they are erased at runtime, so they constrain only code the compiler saw, and a plain `as` cast or an untyped caller removes them entirely. So "read-only" answers *can this handle mutate?* Immutability answers *can anything mutate?* Those are different questions, and only the second one is transitive. ## What a language must take away to guarantee transitivity A language can only promise the deep form by removing something. **Rust** removes aliasing-plus-mutation. Through a shared reference nothing reachable is writable, and while that reference lives, no mutable path to the same data may exist. Opting out means writing the escape into the type — `Cell`, `RefCell`, `Mutex`, `RwLock` — so a reader sees from the signature where mutation can happen. The cost is paid in *data shape*: doubly linked lists, parent pointers, back-references and shared subgraphs cannot be expressed as plain references, and get re-expressed with reference counting plus weak back-edges, or as indices into an arena where the "pointer" is an integer and the arena owns everything. That restructuring, not the syntax, is the standard complaint about learning the language. **Clojure** removes mutation from the default toolkit: vectors, maps and sets are persistent and deeply immutable by construction, and updates return new values sharing most of the old structure. There is nothing to enforce because there is nothing to forbid — the guarantee leaks only at interop with host mutable collections. **Swift** removes sharing for part of the language: a `let` on a struct whose members are all structs freezes the whole tree, because value semantics mean nobody else has a handle to the interior. Embed a class instance and the guarantee stops exactly at that member. "Is every member, transitively, a value type?" is the deepness test. Across the rest — Java, Kotlin, C#, Python, JavaScript — transitivity is a **review obligation**, not a checkable property. The standard techniques are to make every component itself a deeply immutable type, to snapshot at the boundary instead of wrapping a live structure, and to prefer immutable-by-construction collection libraries over read-only interfaces. ## The practical reading When someone tells you a type is immutable, do not accept the label; walk the fields. For each one ask whether it is a primitive or a genuinely deep immutable value, and whether the constructor received a reference that someone else still holds. The point where you cannot answer is the point where the guarantee is aspirational. Choosing the strong version costs allocation and flexibility in data shape; choosing the weak version costs an invariant no test can reliably catch.
- How would you make a read-only handle into an actual guarantee in a language that only offers views?Snapshot instead of wrap: copy the contents into a structure that has no mutating operations at all, and drop or never expose the source reference. The distinction is between a wrapper that forwards to a live structure and a value built from a copy — only the second survives the producer keeping its own handle. Better still, use an immutable-by-construction collection type at the field's declaration so the copy happens once at the boundary rather than at every accessor.
- Why does deep immutability plus final fields still not make an object safe to publish across threads in every language?Deep immutability removes mutation but not visibility problems. In a memory model like Java's, safe publication of an object with final fields is guaranteed by the freeze action at the end of the constructor — but only if the reference does not escape during construction. If `this` leaks from the constructor, another thread can observe a partially built object with default-valued fields, and immutability of the source code does not save you. Deep immutability plus safe publication is the actual rule.
- If transitive immutability is strictly better, why do most languages stop at shallow?Because transitivity constrains the shape of data, not just the writes. Cycles, back-references and shared subgraphs are natural in object models and cannot be built out of plain immutable values, so a language enforcing transitivity must supply reference counting with weak edges, arena indices, or explicitly named interior mutability, and force programmers to use them. Shallow immutability is the compromise: it costs nothing in expressiveness and makes the common case — a value object over primitives and other value objects — correct in practice.
A read-only handle is a window into a room, not a photograph of it. You cannot rearrange the furniture through the window, but whoever is inside still can — and you will see it happen. Immutability is the photograph.
saying these in an interview costs you the question
- Saying a read-only interface type makes the data immutable, when the mutable implementation can be upcast to it for free
- Claiming a final or val field means the referenced object cannot change
- Treating a record or frozen dataclass as deeply immutable when a component is a collection or array
- Assuming a compile-time read-only annotation still constrains anything at runtime in an erased type system
- Saying "immutable objects are automatically thread-safe" with no mention of safe publication or of the transitivity of the field types