skip to content

Why does `if (value is List<String>)` fail to compile in Kotlin, while `if (value is List<*>)` is allowed?

level: middleimportance: must knowfreq 62%

answer

  1. is needs runtime info; erasure removed it
  2. List<String> is check = hard compile error
  3. List<*> star projection = allowed
  4. as List<String> = unchecked-cast warning, fails late
  5. Inside is List<*> elements are Any?

basics

~20 s

At runtime the program can't tell what's inside a list, only that it's a list. So checking is List<String> is impossible and Kotlin rejects it; is List<*> (a list of anything) is checkable, so it's allowed.

solid answer

~50 s

An `is` check is a runtime test, but after erasure there is no runtime information about a list's element type — only that the object is a `List`. Kotlin therefore forbids `is List<String>` outright (a compile error, not a warning) because the check could never be honest. The only permitted form is the **star projection** `is List<*>`, which asks merely "is this a List of some unknown type?" — something the runtime *can* answer. The cast form `as List<String>` does compile but emits an **"unchecked cast"** warning, because the cast to `List` succeeds while the `<String>` part is unverifiable, so a wrong element type only blows up later as a `ClassCastException` when you read an element. To truly check element type you must inspect elements yourself (`all { it is String }`) or use a `reified` inline helper.

code

kotlin · 11 lines
kotlin
fun handle(x: Any) {
    if (x is List<*>) {            // allowed
        // x: List<*>, elements are Any?
        val onlyStrings = x.all { it is String }
        println(onlyStrings)
    }
    // if (x is List<String>) {}   // compile error: erased type
}

@Suppress("UNCHECKED_CAST")
fun risky(any: Any): List<String> = any as List<String> // warns; may fail on read

go deeper

for a junior

Knows is List<String> is rejected and is List<*> is the allowed form.

for a middle

Explains why (erasure), what List<*> means, and that the cast only warns and fails late.

for a senior

Describes the deferred ClassCastException mechanics and safe element-checking strategies including reified helpers.

for a principal

Reasons about API design that avoids relying on runtime type-argument checks and the cost/safety trade-offs of unchecked casts at boundaries (e.g. deserialization).

## The `is` operator and what it needs Kotlin's **`is`** operator performs a runtime **type check** and smart-cast. To evaluate `x is T`, the JVM must be able to inspect the object and decide whether it matches `T`. For a non-generic type like `String`, the object carries its class, so this works. ## Why the generic argument can't be checked Because of **type erasure**, a `List<String>` instance carries no record of `String`. So `value is List<String>` would require runtime info that doesn't exist. Rather than silently doing the wrong thing, Kotlin makes it a **hard compile error**: *"Cannot check for instance of erased type: List<String>".* ## Why `is List<*>` is allowed The **star projection** `List<*>` means "a `List` of *some* type I won't name." That maps exactly to what the runtime knows — the raw `List` class — so the check is honest and permitted: ```kotlin fun handle(x: Any) { if (x is List<*>) { // OK: "is it a list at all?" // x is smart-cast to List<*>; elements are of type Any? for (e in x) println(e) } // if (x is List<String>) ... // compile error } ``` Inside the branch, elements have the safest type **`Any?`**, because we genuinely don't know their type. ## The cast loophole and its warning Kotlin lets you *cast* with `as`: ```kotlin @Suppress("UNCHECKED_CAST") val strings = anyList as List<String> // compiles, but warns: unchecked cast ``` The `as List<...>` part checks only that the object is a `List`; the `<String>` is **unchecked**. If the list actually held `Int`, the cast *appears* to succeed and the failure is deferred: you get a `ClassCastException` only when an element is read and the compiler-inserted `CHECKCAST String` fires. That deferred, misleading failure is exactly why the compiler warns. ## How to actually check element type - Inspect elements: `x is List<*> && x.all { it is String }` (note: an **empty** list passes, and this is O(n)). - Use a **`reified`** inline function so the type is known at the call site (sibling topic), e.g. `inline fun <reified T> List<*>.allAre() = all { it is T }`. ## Summary table - `is List<String>` → compile **error** (erased type can't be checked). - `is List<*>` → **allowed** (raw class is known). - `as List<String>` → compiles, **unchecked-cast warning**, fails late.

  • When exactly does an unchecked `as List<String>` cast throw if the list actually held `Int`?
    Not at the cast site. The cast to raw `List` succeeds; the exception is thrown later when you read an element and the compiler's auto-inserted `CHECKCAST String` runs, producing a `ClassCastException` at that line.
  • Does `is List<*>` give you smart-cast access to elements as their real type?
    No. Elements are typed `Any?` inside the branch, because the star projection deliberately hides the argument. You must check or cast each element individually.

saying these in an interview costs you the question

  • Saying `is List<String>` only warns rather than failing to compile
  • Believing the unchecked cast fails immediately at the cast site
  • Treating `List<*>` and `List<Any?>` as identical (projection vs. concrete arg)
  • Claiming elements inside `is List<*>` are smart-cast to a specific type
  • Suggesting you can suppress the error for `is` like you suppress the cast warning

context