A teammate captures the builder reference out of `buildList` and mutates it after the block returns. Why is this a bug, and what does the contract guarantee?
answer
- No defensive copy: result IS the builder
- Receiver must not escape the lambda
- Read-only interface, but object is mutable underneath
- Compiler can't enforce; it's a discipline contract
- Need lasting mutation? use mutableListOf instead
basics
~20 sThe build* functions only promise the result is read-only because you stop touching the builder when the block ends. If you keep a reference and mutate it afterward, you can silently change a list everyone thinks is frozen — undefined behavior.
solid answer
~40 s`buildList`/`buildSet`/`buildMap` return the builder typed as a read-only interface *without copying*. The standard library's contract is that the `builderAction` lambda must not let the mutable builder *escape* — once `buildList` returns, you must treat the result as immutable and never mutate the original `MutableList` again. If you smuggle out the receiver (e.g., assign `this` to an outer `var`) and call `add`/`remove` after the block, you mutate the very object handed back as a read-only `List`, breaking the immutability assumption callers rely on. The compiler can't enforce this (the receiver is an ordinary reference), so it's a discipline contract. The safe pattern is to keep all mutation inside the block; if you need ongoing mutation, use `mutableListOf()` explicitly instead.
code
kotlin · 7 lines// WRONG
var escaped: MutableList<Int>? = null
val result: List<Int> = buildList { escaped = this; add(1) }
escaped!!.add(2) // mutates the supposedly read-only `result`
// RIGHT — mutate only inside the block
val ok: List<Int> = buildList { add(1); add(2) }go deeper
May not know the no-copy detail; learns the result must be treated as immutable.
Knows you shouldn't mutate after the block but may not articulate the no-copy/same-object reason.
Explains the no-defensive-copy design, escape contract, and that the compiler can't enforce it.
Frames it as an API guarantee callers rely on, discusses copy-elision vs safety trade-offs and how to design APIs that don't leak mutability.
## The contract `buildList` (and `buildSet`/`buildMap`) are documented as returning a read-only collection built by the `builderAction`. The crucial clause: *the collection passed as the receiver should not be mutated outside of the `builderAction`*, and the builder reference should not escape the lambda. Because the function returns the **same** object (no defensive copy) cast to the read-only interface, mutating the original after the fact mutates the "immutable" result. ## The bug in code ```kotlin lateinit var leaked: MutableList<Int> val frozen: List<Int> = buildList { add(1); add(2) leaked = this // escape! capturing the mutable receiver } // callers believe `frozen` is immutable: println(frozen) // [1, 2] leaked.add(99) // CONTRACT VIOLATION println(frozen) // [1, 2, 99] <-- the "read-only" list changed! ``` `frozen` and `leaked` are the **same** `ArrayList` instance, just viewed through different static types. There is no runtime barrier; the read-only `List` interface simply lacks mutation methods, but the object underneath is fully mutable. ## Why the compiler can't stop it Kotlin has no built-in ownership/escape analysis for this. The receiver is a normal reference; assigning `this` to an outer variable is legal Kotlin. So this is a **discipline / contract** rule, similar to not casting a `List` to `MutableList`. ## Related anti-pattern: downcasting the result ```kotlin val xs: List<Int> = buildList { add(1) } (xs as MutableList<Int>).add(2) // also a contract violation; relies on impl detail ``` This happens to work because the backing object is an `ArrayList`, but it is undefined per contract and may break. ## Correct usage - Do all mutation **inside** the block; let `buildList` return the frozen view. - If you truly need a long-lived mutable collection, use `mutableListOf()`/`mutableMapOf()` directly and own that mutability explicitly. - Never assign the receiver (`this`) to anything that outlives the block. ## Why it matters in practice Returning a read-only `List` is an API guarantee callers depend on for thread-safety reasoning and defensive-copy elision. Violating it can cause spooky action-at-a-distance bugs that are extremely hard to trace.
- Why does `buildList` not just defensively copy to make this safe?Avoiding the copy is a deliberate performance choice; the read-only contract relies on the builder not escaping, so a copy is unnecessary if you follow the rule.
- Is casting the returned `List` to `MutableList` safe?No — it relies on an implementation detail (currently ArrayList) and violates the contract; the result must be treated as immutable.
It's like signing a 'final' document, then keeping the pen and editing the original after it's filed — everyone trusts the copy on file is final.
saying these in an interview costs you the question
- Believing the read-only `List` is a separate immutable copy
- Saying the compiler prevents mutating after the block
- Recommending casting the result to MutableList as a normal technique
- Not recognizing it returns the same object, no copy
- Claiming there's a runtime exception on later mutation (there isn't)