skip to content

How does star projection interact with runtime type checks (`is`/`as`) and generic erasure? Why does `x is List<*>` compile while `x is List<String>` does not?

level: seniorimportance: should knowfreq 40%

answer

  1. JVM erases type arguments at runtime
  2. Only `is List<*>` allowed; `is List<String>` won't compile
  3. Star every parameter in an `is` check
  4. `as List<String>` -> unchecked cast, delayed CCE
  5. reified + inline (filterIsInstance) to check elements

basics

~20 s

Kotlin erases generic type arguments at runtime, so you can't check is List<String> — the element type isn't there to check. The star projection is List<*> only checks that it's a List at all, which is exactly what's verifiable after erasure.

solid answer

~40 s

JVM generics are **erased**: at runtime a `List<String>` and a `List<Int>` are both just `List`. So `x is List<String>` is rejected with 'cannot check for instance of erased type.' The **only** type-argument shape allowed in an `is`/`as` check is the star projection: `x is List<*>` succeeds because it checks solely that `x` is some `List`, ignoring the erased element type. After a successful `x is List<*>`, the smart-cast type is `List<*>`, so reads are `Any?`. `as List<String>` compiles but triggers an *unchecked cast* warning and gives no runtime guarantee — a `ClassCastException` may surface later when you read an element. Use `reified` type parameters in `inline` functions (e.g. `filterIsInstance<String>()`) when you need to check concrete element types.

code

kotlin · 13 lines
kotlin
fun summarize(x: Any): String = when (x) {
    is List<*> -> "list of ${x.size}"   // only star form allowed
    is Map<*, *> -> "map of ${x.size}"   // star every parameter
    else -> "other"
}

inline fun <reified T> Iterable<*>.onlyOf(): List<T> =
    filterIsInstance<T>()   // reified makes per-element checks possible

fun main() {
    println(summarize(listOf(1, 2, 3)))            // list of 3
    println(listOf(1, "a", 2).onlyOf<Int>())       // [1, 2]
}

go deeper

for a junior

Knows generics are erased and that is List<*> is the allowed form.

for a middle

Explains the compile error for is List<String> and the unchecked-cast warning for as.

for a senior

Uses reified/filterIsInstance correctly and reasons about delayed ClassCastException.

for a principal

Discusses erasure trade-offs, when unchecked casts are justified, and array reification differences.

## Erasure: the root cause On the JVM, generic **type arguments are erased** at runtime. `List<String>`, `List<Int>`, and `List<*>` all share the same runtime class. The compiler therefore can't emit a check that distinguishes element types. ## What you can and cannot write ```kotlin fun check(x: Any) { if (x is List<*>) { // OK: only checks 'is it a List?' val first: Any? = x.firstOrNull() } // if (x is List<String>) {} // COMPILE ERROR: cannot check erased type } ``` - **`x is List<*>`** is allowed because it asks only "is this a `List` of *something*?" — answerable after erasure. - **`x is List<String>`** is a compile error: *"Cannot check for instance of erased type."* - The star is the **only** permitted argument form in `is`. You must star *every* parameter: `x is Map<*, *>`, not `Map<String, *>`, in an `is` check. ## Smart-cast result After `x is List<*>` the compiler smart-casts `x` to `List<*>`, so element reads are `Any?` and writes (if it were mutable) are `in Nothing`. ## Unchecked casts ```kotlin @Suppress("UNCHECKED_CAST") val strings = anyList as List<String> // unchecked: no runtime verification ``` `as List<String>` compiles but produces an **unchecked cast** warning. The cast only verifies the object is a `List`; the `String` part is unverifiable, so a wrong element type fails *later* with `ClassCastException` at the point of use (a 'delayed' CCE). Treat unchecked casts as assertions you must justify. ## reified to recover element checks For checking *element* types, use an `inline` function with a `reified` type parameter: ```kotlin inline fun <reified T> Iterable<*>.onlyOf(): List<T> = filterIsInstance<T>() // reified keeps T at runtime; checks each element ``` `reified T` keeps `T` available at runtime so per-element `it is T` works — but it checks each element, it does *not* let you check `List<T>` as a whole. ## Arrays differ Arrays are reified on the JVM (`Array<String>` keeps its component type), unlike erased generics — but for the generic `is` check the rule stands: only `*` is allowed. ## Practical guidance - Use `is List<*>` to branch on container shape. - Use `filterIsInstance<String>()` (a stdlib reified helper) instead of unchecked casts when you need typed elements. - Suppress `UNCHECKED_CAST` only with a clear invariant comment.

  • Why does `as List<String>` compile but `is List<String>` doesn't?
    `as` is allowed as an *unchecked* cast (compiler warns and skips element verification, risking a later `ClassCastException`), whereas `is` must give a definite runtime answer, which erasure makes impossible for a concrete element type — so it's a hard error.
  • How would you safely extract only the `String` elements from a `List<*>`?
    Use the stdlib `filterIsInstance<String>()`, an inline reified helper that checks each element with `it is String` at runtime — no unchecked cast needed.

Erasure is like shipping boxes with the contents-label torn off: you can confirm it's a box (is List<*>) but not what's inside (is List<String>) without opening each item (a reified per-element check).

saying these in an interview costs you the question

  • Claiming `is List<String>` works at runtime
  • Thinking `as List<String>` actually verifies element types
  • Not knowing every parameter must be starred in an `is` check
  • Confusing erased generics with reified arrays/`reified` params
  • Suppressing UNCHECKED_CAST without any justifying invariant

context