skip to content

Identity, Equality & Immutability

What makes an object the same one versus merely an equal one, the laws an equality check must obey, and what immutability buys and costs. Interviewers mine it because both go wrong daily.

on this pageshow

questions

11

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.

level: middleimportance: must knowfreq 55%

answer

  1. Binding vs shallow vs transitive — three different promises
  2. Read-only type = restricted view, not a property of the value
  3. Kotlin List upcast from MutableList: free, no copy, no guarantee
  4. C# IReadOnlyList / TS ReadonlyArray: same hole, TS erased at runtime
  5. Transitivity is paid for in data shape: Rc/Weak, arena indices, named interior mutability

basics

~20 s

Binding 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 s

Three 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 lines
text
backing = 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 frozen

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

Several languages generate equality and hashing for you from a type's declared members — Kotlin data classes, Scala case classes, C# records and Java records among them. Compare what these generated implementations actually include, and where each one still leaks.

level: middleimportance: should knowfreq 35%

basics

~20 s

They differ on three axes: which members count (Kotlin uses primary-constructor properties only; C# records use all instance fields), the subtype policy (Scala's canEqual, C#'s runtime equality contract, final Java records), and reference members such as arrays, which all four compare by identity.

open as a page

When is it safe to use identity comparison ("are these two references the same object?") as a stand-in for value equality? Name languages where this is a guarantee and languages where it only appears to work.

level: middleimportance: should knowfreq 45%

basics

~20 s

Only when the type guarantees exactly one canonical instance per value. Erlang and Elixir atoms, Lisp and Scheme symbols, Ruby symbols and JavaScript's Symbol.for registry give that guarantee. Java's small-Integer cache and CPython's interned strings do not; they are optimizations.

open as a page

Some languages let you declare a type whose instances are copied whenever they are assigned or passed, while others give every user-defined type reference semantics. Compare the two language models and say what each one costs the programmer.

level: middleimportance: should knowfreq 45%

basics

~20 s

Value semantics: assignment copies the object, so two names never alias one state. Reference semantics: assignment copies a handle. C#, Swift, Go and C++ let the type's author choose; in Java, Python and JavaScript every object is a reference.

open as a page

IEEE-754 specifies that a NaN floating-point value is not equal to itself. Which law of an equivalence relation does that break, and how do different languages' hash containers and sorting routines cope with a value that is not equal to itself?

level: seniorimportance: should knowfreq 28%

basics

~20 s

It breaks reflexivity, the base law. Languages cope differently: JavaScript keys Map and Set with SameValueZero so NaN works as a key; Go accepts a NaN map key you can never read back; Rust withholds the Eq trait, so a float cannot key a hash map at all.

open as a page

Some standard libraries let a hash container carry its own equality: C++'s std::unordered_map takes Hash and KeyEqual template arguments, and .NET's Dictionary<TKey,TValue> accepts an IEqualityComparer<T> at construction. Others fix the relation on the key type — java.util.HashMap has no such hook, and Go's map always uses the language's == operator. Suppose you need one case-insensitive, Unicode-normalised index over strings while ordinary string equality stays exact everywhere else in the program. How do you build that on each side of the divide, and what is the design argument for the keying relation belonging to the container rather than to the type?

level: seniorimportance: should knowfreq 38%

basics

~20 s

A lookup relation belongs to the index, not the type. C++ and .NET let a container carry its own hash-and-equality pair; Java's HashMap and Go's map cannot, so you canonicalise or wrap the key instead. Never loosen the type's own equality.

open as a page

Producing a modified copy instead of mutating in place costs something. Compare the strategies real languages use for that cost -- eager copying, copy-on-write with a uniqueness check, and persistent structures with structural sharing -- and say where each one surprises people in production.

level: seniorimportance: should knowfreq 42%

basics

~20 s

Eager copying is linear every time, as with Java's defensive array and collection copies. Copy-on-write is constant until a second reference exists, so Swift arrays turn linear the moment one is captured. Persistent structures such as Clojure's vectors copy only a path of nodes.

open as a page

You need a value type that guarantees "start is never after end", and you write it with your language's boilerplate generator — a Java record, a Kotlin data class, a Scala case class, a C# record, a Python frozen @dataclass. Each of these also hands callers a way to produce a modified copy (copy(...), the C# with expression, dataclasses.replace). Before you trust such a type to hold that rule, which construction paths would you enumerate, and where do these languages genuinely diverge?

level: seniorimportance: should knowfreq 25%

basics

~20 s

Enumerate every construction path the generator adds, not just the one you wrote. C# with-expressions clone the fields and assign through init setters, so no constructor body re-runs; Kotlin and Scala copy() re-runs the primary constructor; Java records add no copy path at all.

open as a page

A value-type instance is mutated through a member call, the code compiles and runs with no error, and afterwards the value is unchanged. In C# this happens when the call target is a readonly field, an `in` parameter, or the result of a property that returns the struct by value. Explain the mechanism behind the vanishing write, what `readonly struct` (C# 7.2) and readonly members (C# 8) change, and how Swift, Rust, Go and C++ each answer the same 'mutating something that is really a temporary' question differently.

level: seniorimportance: should knowfreq 30%

basics

~20 s

The write landed on a copy the compiler inserted. C# copies a struct before invoking a non-readonly member on a readonly field, an in parameter, or a by-value property result, so the mutation hits a discarded temporary. Declaring the type a readonly struct removes both copy and trap.

open as a page

Some languages give certain types no stable reference identity at all: structs are copied on assignment, or values are moved. How do you model an entity whose identity must persist in such a language, and what changes once the entity crosses a process or storage boundary?

level: principalimportance: should knowfreq 33%

basics

~20 s

Reify identity as a field. Go copies structs on assignment, Swift gives === only to classes, Rust moves values and offers Rc::ptr_eq only on request. No address survives serialization anywhere, so entities carry an explicit key and compare by it.

open as a page

A value type in your system is used as a key in a hashed cache, stored in a sorted collection, deduplicated after JSON serialization, and compared by client code written in three different languages. Which equivalence relations are actually in play, and how do you keep them from disagreeing?

level: principalimportance: nice to knowfreq 20%

basics

~20 s

At least four: hash equality, the ordering's own equivalence, serialized-byte equality, and each client language's default structural comparison. Choose one canonical relation, derive the others from it, specify it outside any one language, and document every place a coarser relation is intended.

open as a page