JavaRush /Courses /Kotlin SELF /Type erasure and inline/reified: is T, as? T, and T::clas...

Type erasure and inline/reified: is T, as? T, and T::class checks

Kotlin SELF
Level 36, Lesson 4
Available

1. Type erasure on the JVM

When you’re just starting to write generic functions, a very human desire appears: “Now I’ll write fun <T> ..., and inside I’ll check x is T and everything will look nice.” It’s like having a box labeled “cookies inside” and thinking that at any moment you can open it and confirm there really are cookies in there, not socks.

But Kotlin (more precisely, Kotlin on the JVM) suddenly says: “No, you can’t do that.” And this is not the compiler being picky—it’s protection from false confidence. If such a check were allowed, it would often work “as if” it were correct, and then fail very unpleasantly in the strangest places.

The reason is type erasure (type erasure).

What type erasure is and what exactly gets “erased”

Type erasure is a JVM-world rule: type parameters (what’s inside <...>) in most cases exist only at compile time, and at runtime the program operates on a “generic shape.” For example, List<Int> and List<String> at runtime look simply like List (roughly “a list of something”). Kotlin honestly warns: instances of generic types do not store information about their actual type arguments—the “angle brackets” get erased.

Why is it done this way? Historically, the JVM was designed so that Java generics would not break compatibility with old code. So generics are primarily compiler checks and guarantees, not “magic tags” inside objects at runtime.

You can visualize it like this:

flowchart LR
    A["Source: List⟨Int⟩, List⟨String⟩"] --> B["Compiler: verified type safety"]
    B --> C["Bytecode/JVM: at runtime it’s just List"]
    C --> D["You can no longer distinguish ⟨Int⟩ vs ⟨String⟩"]

Why checks like is List<Int> and is T are forbidden, and what is allowed instead

Now it’s clearer why Kotlin forbids checks like x is List<Int>: at runtime you cannot reliably distinguish a “list of Int” from a “list of String,” because those type arguments are erased. Kotlin states it directly: due to type erasure there is no general way to check which type arguments were used to create a generic instance, and therefore is checks like ints is List<Int> or list is T (where T is a type parameter) are forbidden.

Instead, a safe compromise is allowed: a star-projection check, for example is List<*>. This means “this is a list of something, the element type is unknown,” and the check is honest: it promises nothing about elements, so the compiler allows it.

Example—“what you can do”:

fun main() {
    val x: Any = listOf(1, 2, 3)

    if (x is List<*>) {
        println("This is a list of size ${x.size}") // This is a list of size 3
        println("First element: ${x[0]}")     // First element: 1
    }
}

Note: after x is List<*> the elements are treated as Any?. That’s logical: since we don’t know the element type, the safest assumption is “something.”

A “raw” type without <...>: what if (list is ArrayList) means

There’s another useful trick that at first looks like a cheat code: sometimes you can check only the “outer” class, without touching type arguments. For example, ArrayList is a concrete list implementation, and you can check “is this an ArrayList or not” even if you don’t know whether it has <String> or <Int> inside. Kotlin allows such checks, but with an important detail: you must omit the angle brackets.

Example:

fun main() {
    val names: MutableList<String> = arrayListOf("Ann", "Bob")

    if (names is ArrayList) {
        println("Yes, this is an ArrayList. size=${names.size}") // Yes, this is an ArrayList. size=2
    }
}

Here Kotlin can smart-cast to ArrayList<String> (because at compile time it already knows that names is a MutableList<String>). But the runtime check itself was about the “raw” ArrayList without <...>.

3. inline and reified: how to legally do is T, as? T, and T::class

Starting point: “what if I still need to know the type?”

At this point a fair engineering question appears: “If the type is erased, how do you write generic things that depend on the type at all?” There are two basic approaches. The first is to pass a “type token” explicitly (in Java it’s often Class<T>, in Kotlin it’s KClass<T>). The second is to use what Kotlin can do: inline + reified.

In this lecture we focus on the second method, because it looks like magic, but in reality it’s a neatly packaged “compiler code substitution.” Kotlin emphasizes: ordinary generic functions cannot use a type parameter for is checks and casts, and the exception is inline functions with reified type parameters.

inline: what it means to “paste the code into the call site”

The word inline literally means: “the compiler will substitute the function body directly at the call site.” This is especially useful for higher-order functions (with lambdas) to avoid extra allocations, but for us something else matters more: if the function body is substituted at the call site, then the concrete type T becomes known to the compiler right there.

We don’t need to dive into bytecode, but it helps to keep a simple picture in mind:

flowchart TD
    A["Call: foo<String>(x)"] --> B["Compiler sees T=String"]
    B --> C["Inlines foo body right here"]
    C --> D["Inside the body you can write x is String"]

reified: how to make T “visible” inside the function

reified is a type-parameter modifier that is allowed only on inline fun. It means: “type T will be available for runtime checks inside this function,” as if T were an ordinary class. Kotlin demonstrates the idea directly: with reified you can write x is T and x as? T without reflection and without manually passing Class<T>.

It’s worth memorizing a short rule: reified only exists together with inline.

Why an “ordinary” generic function does not compile

Here is the code idea you really want to write, but it won’t compile (and that’s normal):

// Idea (does not compile):
// fun <T> isOfType(x: Any): Boolean = x is T

The compiler forbids it because T is “erased” at runtime.

The same idea, but correctly: inline + reified

inline fun <reified T> isOfType(x: Any): Boolean = x is T

fun main() {
    val a: Any = "hello"

    println(isOfType<String>(a)) // true
    println(isOfType<Int>(a))    // false
}

An important trick (no rabbits involved) happens here: when calling isOfType<String>(a) the compiler substitutes the check a is String. And for isOfType<Int>(a) it substitutes a is Int.

as? T: safe casting in generic utilities

An is check is great when you need a Boolean. But in application code you more often want to “try to cast, and if it doesn’t work, keep living peacefully.” For that, safe cast as? is perfect: it returns null if the cast is impossible.

Let’s make a universal function castOrNull:

inline fun <reified T> castOrNull(x: Any): T? = x as? T

fun main() {
    val x: Any = 42

    val s: String? = castOrNull<String>(x)
    val n: Int? = castOrNull<Int>(x)

    println("s=$s") // s=null
    println("n=$n") // n=42
}

Now let’s add a human-friendly message via the Elvis operator ?: (which you’ve used many times in null-safety):

inline fun <reified T> castOrDefault(x: Any, default: T): T {
    return (x as? T) ?: default
}

fun main() {
    val x: Any = "text"

    val num = castOrDefault<Int>(x, default = -1)
    println(num) // -1
}

The key point is that we do not crash with an exception; we choose a fallback plan.

T::class: a class “token” for messages, logs, and small checks

Sometimes you don’t need to cast so much as you need to clearly explain what you expected. For this, T::class is useful: it returns a KClass object, i.e., “a class description as a value.” Kotlin shows that with reified you can access T::class.

Let’s make a small function that prints the expected type name:

inline fun <reified T> expectedTypeName(): String {
    return T::class.simpleName ?: "<unknown>"
}

fun main() {
    println(expectedTypeName<String>())   // String
    println(expectedTypeName<List<*>>())  // List
}

And now a more practical version: “require a type, otherwise fail with a normal message.” Here we use error(...), which returns Nothing (you’ve already seen this when discussing Nothing and “the function does not return”).

inline fun <reified T> requireType(x: Any): T {
    return (x as? T) ?: error("Expected ${T::class}, got ${x::class}")
}

fun main() {
    val x: Any = "hi"
    val s: String = requireType(x)

    println(s) // hi
}

This helper is very handy for internal checks in an application when you’re confident in the logic, but want the failure message to be not “ClassCastException somewhere over there,” but “expected one thing, got another.”

Limitations of reified: why List<String> is still slippery

Now a dangerous thought may appear: “If reified makes type T visible, then I can do x is List<String>.” Sadly, no: type arguments inside generic types are erased as well. Kotlin explicitly warns that even with reified the limitations remain: if arg itself is a generic type, its type arguments are still erased.

This means that a direct “this is a list of strings” check is still problematic. But you can make an honest, logical two-step check: first ensure it’s a List<*>, and then check each element.

fun main() {
    val x: Any = listOf(1, 2, 3)

    if (x is List<*>) {
        val allStrings = x.all { it is String }
        println(allStrings) // false
    }
}

Yes, it’s not as pretty as x is List<String>, but it actually works and doesn’t lie to your face.

4. Practice: an “event log” and selecting elements by type

Imagine that in our educational console application (which we develop throughout the course) we started keeping a simple event log. Sometimes people do this even in small programs—not for architecture, but for debugging. The problem is that events can have different types, and in the simplest version they are stored in a List<Any>.

Here reified becomes very practical: you can write a utility “pull all events of a certain type from the log”— without manual as and without a bunch of if.

First, let’s define two event types:

data class ExpenseAdded(val amount: Double)
data class NoteAdded(val text: String)

Now let’s write a function “pick all elements of type T from a list of Any”:

inline fun <reified T> pickAllOfType(items: List<Any>): List<T> {
    val result = mutableListOf<T>()
    for (item in items) {
        if (item is T) result.add(item)
    }
    return result
}

And use it:

fun main() {
    val log: List<Any> = listOf(
        ExpenseAdded(10.0),
        NoteAdded("coffee"),
        ExpenseAdded(25.5),
    )

    val expenses = pickAllOfType<ExpenseAdded>(log)
    println(expenses) // [ExpenseAdded(amount=10.0), ExpenseAdded(amount=25.5)]
}

This is a good example where reified really removes “noise” from code: you don’t write loops with as? by hand every time, and you don’t risk accidentally doing an unsafe as.

5. Common mistakes when working with type erasure and inline/reified

Mistake #1: expecting that fun <T> can do x is T without reified.
This is a common logical trap: it feels like if T is written in the code, then it also “exists” at runtime. On the JVM it mostly exists for the compiler, and at runtime it’s erased, which is why Kotlin forbids such a check. There is only one way out: either pass a “type token” explicitly, or use inline fun <reified T>—and remember that reified never exists without inline.

Mistake #2: trying to check nested type arguments like x is List<String> and treating it as reliable.
Even if you really want to prove in a single expression that “this is a list of strings,” the runtime doesn’t know that, because String inside List<...> is erased. The correct style is to check the outer type via is List<*>, and then check the elements with logic. Kotlin separately warns that generic arguments stay erased even when you use reified.

Mistake #3: using as in generic utilities instead of as?, causing crashes “out of nowhere.”
as means “trust me, I know the type,” and if you’re wrong it throws an exception. In utilities that process Any or data of an “unknown nature,” it’s almost always more reasonable to start with as? and return T?, and then decide via ?: (provide a default) or via an explicit error with a clear message.

Mistake #4: treating inline as a free optimization and writing inline “just in case.”
inline is a tool that changes how code is generated (the body is copied into call sites). It’s useful when you need reified, or when you truly benefit with lambdas, but it’s not a “speed up everything” button. Sometimes inline increases bytecode size and makes debugging harder. In this lecture we use inline not for speed, but for the ability to make T available for checks.

1
Task
Kotlin SELF, level 36, lesson 4
Locked
Submission check
Submission check
1
Task
Kotlin SELF, level 36, lesson 4
Locked
Safe cast
Safe cast
1
Task
Kotlin SELF, level 36, lesson 4
Locked
Event filter
Event filter
1
Survey/quiz
Advanced generics, 36 level, 4 lesson
Unavailable
Advanced generics
Advanced generics
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION