skip to content

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?

level: seniorimportance: should knowfreq 40%

answer

  1. Read-only List erases to mutable java.util.List for Java
  2. Returning backing list = representation exposure bug
  3. unmodifiableList → throws on mutation at runtime
  4. toList() = defensive copy protecting internal state
  5. Strongest = copy + unmodifiable / kotlinx immutable

basics

~20 s

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

Kotlin'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 lines
kotlin
class 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) -> UnsupportedOperationException

go deeper

for a junior

Recognizes that List looks read-only but may not see why Java can still mutate it.

for a middle

Explains the erasure to java.util.List and proposes toList() or unmodifiableList.

for a senior

Chooses the right guarantee (copy vs wrapper vs immutable) for the contract and weighs copy cost; avoids representation exposure.

for a principal

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

context