You expose a Kotlin API returning List<User> to Java consumers and want them to NOT mutate it. Given how Kotlin collections map to java.util, why is the bare return type insufficient, and what would you do?
answer
- Read-only List erases to mutable java.util.List for Java
- Returning backing list = representation exposure bug
- unmodifiableList → throws on mutation at runtime
- toList() = defensive copy protecting internal state
- Strongest = copy + unmodifiable / kotlinx immutable
basics
~20 sReturning Kotlin's read-only List doesn't stop Java callers from mutating it, because at runtime it's just a java.util.List with add/remove. To actually prevent mutation, return an unmodifiable wrapper or a truly immutable collection, and consider a defensive copy.
solid answer
~30 sKotlin's List is a mapped type that erases to java.util.List; the read-only/mutable split is compile-time and Kotlin-only. A Java consumer sees java.util.List<User> with full mutators and can call add()/remove(), possibly corrupting your internal state if you returned a backing collection. Fixes, strongest to weakest: (1) return Collections.unmodifiableList(copy) or a kotlinx.collections.immutable ImmutableList — mutation throws UnsupportedOperationException at runtime; (2) return a defensive copy via toList() so external mutation can't touch internal state; (3) annotate the API for Kotlin callers but understand annotations don't constrain Java. Combine a defensive copy with an unmodifiable wrapper for a hard guarantee. Document ownership in the contract.
code
kotlin · 8 linesclass Roster {
private val users = mutableListOf<User>()
// Hard guarantee across the Java boundary:
fun all(): List<User> =
java.util.Collections.unmodifiableList(users.toList())
}
// Java: roster.all().add(x) -> UnsupportedOperationExceptiongo deeper
Recognizes that List looks read-only but may not see why Java can still mutate it.
Explains the erasure to java.util.List and proposes toList() or unmodifiableList.
Chooses the right guarantee (copy vs wrapper vs immutable) for the contract and weighs copy cost; avoids representation exposure.
Establishes codebase-wide conventions for collection ownership at interop boundaries, picks persistent collections for shared/concurrent state, and documents mutation contracts.
## The core problem Kotlin's `List<User>` is a **mapped type**: at the bytecode level it is `java.util.List<User>`. The read-only vs `MutableList` distinction lives **only in Kotlin's type system** and vanishes for Java. So a Java consumer of your `List<User>` sees the full `java.util.List` interface — `add`, `remove`, `set`, `clear` — and can mutate it. If you returned your internal backing list, that's a representation-exposure / encapsulation bug. ```kotlin class Roster { private val users = mutableListOf<User>() fun all(): List<User> = users // BAD: Java (and casts) can mutate internal state } ``` A Java caller: `roster.all().add(hacker);` mutates `Roster`'s private list. Even in Kotlin, a hostile `(roster.all() as MutableList).add(...)` works because it's the same object. ## Options, strongest to weakest **1. Truly immutable / runtime-enforced (best when mutation must be impossible)** ```kotlin import kotlinx.collections.immutable.toImmutableList fun all(): List<User> = users.toImmutableList() // or, JDK-only: fun all(): List<User> = java.util.Collections.unmodifiableList(users.toList()) ``` `unmodifiableList` throws `UnsupportedOperationException` on any mutator — Java included. `toImmutableList()` returns a persistent structure with no mutators at all. **2. Defensive copy (protects internal state; copy itself is still mutable)** ```kotlin fun all(): List<User> = users.toList() // new independent read-only copy ``` `toList()` snapshots; callers mutating the *copy* can't corrupt `users`. But Java can still mutate the *copy*, so combine with (1) if even the returned object must be frozen. **3. Annotations / contract (helps Kotlin tooling, not Java enforcement)** Kotlin callers already can't mutate a read-only `List`. JSpecify/nullability or doc annotations express intent but **do not constrain Java mutators**. Useful as documentation, not as a guarantee. ## Recommended pattern For a public boundary that must not be mutated externally: **defensive copy + unmodifiable wrapper** (or an immutable collection): ```kotlin fun all(): List<User> = java.util.Collections.unmodifiableList(users.toList()) ``` This protects internal state (copy) AND makes the returned object reject mutation at runtime (wrapper). Document who owns/may mutate the result. ## Performance note Defensive copies cost O(n) and an allocation. For hot paths, prefer a persistent immutable collection you already maintain, or expose a `Sequence`/iterator, balancing safety against copy cost.
- Does returning MutableList vs List change anything for a Java caller?No. Both erase to java.util.List, so Java sees the same mutable interface either way. The difference only affects Kotlin callers' compile-time checks.
- When is a defensive copy alone insufficient?When the returned object itself must reject mutation (e.g., shared cached snapshot). The copy protects your internal state but is still a mutable java.util.List; wrap it unmodifiable or return an immutable collection.
- What's the trade-off of Collections.unmodifiableList without copying first?It avoids the O(n) copy but the wrapper is a live view: if the backing list still changes, the 'unmodifiable' view reflects those changes. Copy first for a stable, fully-protected snapshot.
Handing back a read-only List to Java is like locking a door from your side only — the visitor's side still has the handle. To truly lock it you need a deadbolt (unmodifiable wrapper) or a separate room (defensive copy).
saying these in an interview costs you the question
- Believing the read-only return type stops Java mutation
- Returning the backing mutableListOf directly from a public API
- Thinking MutableList vs List matters to Java callers
- Assuming unmodifiableList copies (it wraps a live view)
- Ignoring the O(n) cost of defensive copies on hot paths