skip to content

How does Java interop affect Kotlin's read-only guarantee? If a Kotlin `List<T>` is passed to Java (or comes from Java), what can go wrong?

level: seniorimportance: should knowfreq 40%

answer

  1. both List and MutableList map to java.util.List
  2. Java sees full mutators on a Kotlin read-only List
  3. Java collections enter as platform types (List!)
  4. copy out AND in across the boundary
  5. read-only is a compiler fiction, gone at JVM level

basics

~20 s

Java doesn't know about Kotlin's read-only vs mutable split. A Kotlin List is a plain java.util.List to Java code, which can call add/set on it. And collections coming from Java arrive as platform types Kotlin can't fully police.

solid answer

~40 s

Kotlin's `List`/`MutableList` distinction is a *Kotlin-only* compile-time fiction implemented with `@ReadOnly`/`@Mutable` annotations and a mapping to a single `java.util.List`. At the JVM level there is only `java.util.List`. So Java code receiving a Kotlin read-only `List` sees a `java.util.List` with full `add`/`remove`/`set` and can mutate it — and if that list is your internal `mutableListOf`, you've leaked it. Conversely, a `java.util.List` returned from Java enters Kotlin as a **platform type** (`(Mutable)List!`), where Kotlin won't enforce read-only and nullability is unchecked. Defenses: copy with `toList()` before handing collections across the boundary, and copy/validate Java-supplied collections on the way in. This is the same aliasing problem as within Kotlin, amplified because Java ignores the read-only contract entirely.

code

kotlin · 7 lines
kotlin
// Kotlin
private val secret = mutableListOf("a", "b")
fun leaky(): List<String> = secret          // Java can mutate this
fun safe(): List<String> = secret.toList()  // snapshot, Java can't reach secret

// Receiving from Java -- detach + pin nullability
val xs: List<String> = javaApi.getItems().orEmpty().toList()

go deeper

for a junior

Aware Java can change a list it receives; knows to be careful at the boundary.

for a middle

Knows both Kotlin types map to java.util.List and that Java can call mutators on a read-only list.

for a senior

Explains platform types, copy-in/copy-out discipline, and nullability risks crossing the boundary.

for a principal

Sets API-boundary policy (defensive copy, unmodifiable wrappers, persistent collections) weighing interop cost, safety, and allocation across a large codebase.

## There is only one List on the JVM Kotlin's `kotlin.collections.List` and `kotlin.collections.MutableList` both **map to `java.util.List`** at the bytecode level. The read-only/mutable split exists only in the Kotlin compiler via mapped-type annotations. The JVM has no read-only `List`. Therefore: ### Passing Kotlin read-only List → Java ```kotlin val secret = mutableListOf("a", "b") fun expose(): List<String> = secret // Kotlin sees read-only ``` ```java // Java side List<String> l = obj.expose(); l.add("hacked"); // compiles AND runs -- mutates Kotlin's internal list ``` Java sees a `java.util.List` with all mutators. Kotlin's read-only type vanishes at the boundary, so Java can mutate the very object you intended to protect. ### Receiving a List from Java → Kotlin (platform types) A method `List<String> getItems()` in Java surfaces in Kotlin as a **platform type** written `(Mutable)List<String!>!`. Kotlin: - does **not** enforce read-only — you can call mutators if you assert the mutable type, - does **not** check nullability — a `null` can slip in and NPE later. You choose the Kotlin type at the call site; if you say `val xs: List<String> = java.getItems()` you *believe* it's read-only, but Java may keep mutating the same object behind your back (aliasing again). ## Defenses 1. **Copy on the way out**: `fun expose(): List<String> = secret.toList()` — hand Java a snapshot it can mutate harmlessly. 2. **Copy on the way in**: `val xs = java.getItems().toList()` (or `.orEmpty().toList()` to also handle null) — detach from the Java-owned object and pin nullability. 3. **Prefer immutable element types** so even shared element references can't be abused. 4. For strong guarantees across boundaries, consider `kotlinx.collections.immutable` persistent collections, though these still map to Java collections for interop. ## Why this is the same problem, amplified Within Kotlin, aliasing requires someone to up-cast or cast-back. Across the Java boundary, **every** read-only `List` is silently a fully-mutable `java.util.List`, so the protection is gone by default. Defensive copying is the only reliable mitigation. ## Terms - **Mapped type**: a Kotlin type the compiler maps to a JVM type (`kotlin.collections.List` → `java.util.List`). - **Platform type** (`T!`): a type from Java with unknown nullability/mutability; Kotlin relaxes checks. - **`@ReadOnly` / `@Mutable`**: internal annotations the compiler uses to track the distinction over mapped types.

  • What is a platform type and why does it weaken the read-only guarantee?
    It's a Java-origin type (`List!`) whose nullability and read-only/mutable status Kotlin can't verify, so the compiler relaxes both checks and lets you treat it as mutable or non-null at your own risk.
  • Does returning `Collections.unmodifiableList(...)` from Java make it safe in Kotlin?
    It prevents mutation through that wrapper, but if Java still holds the original backing list it can mutate it and the change shows through. Copy with `toList()` to be sure.

The read-only label is written in a language only Kotlin reads; when the box crosses into Java territory, the label is invisible and anyone can open it.

saying these in an interview costs you the question

  • Believing Java cannot mutate a Kotlin read-only List
  • Not knowing both Kotlin list types map to java.util.List
  • Ignoring platform-type nullability when consuming Java collections
  • Assuming the read-only annotation survives to bytecode
  • Storing a Java-supplied collection in a field without copying

context