Kotlin inline reified to Solve Type Erasure, and a Practical Guide on noinline, crossinline, and More

Ioannis Anifantakis
ProAndroidDev
Published in
13 min read5 days ago

--

Introduction

Kotlin has become very popular, especially for writing native Android apps and recently for multiplatform apps. One of its most powerful features which is a little hard to understand are inline functions used together with reified type parameters. These functions let you write type-safe code and avoid problems caused by type erasure, without relying on slow reflection.

In this guide, I will explain how these functions work, why they are so useful, show a real-world example using type-safe navigation in Android, and discuss their performance benefits. We will build up the concepts step by step, so you can fully understand the reasoning behind this approach.

The Problem: Generics and the Vanishing Type

Before we introduce the solution, we need to clarify the problem. It all starts with generics.

Generics are a wonderful way to write reusable code that can handle different data types. For example, you might want a list that holds various elements — numbers, strings, or custom objects. Generics allow you to do this without writing separate code for each type.

val stringList: List<String> = listOf("apple", "banana", "orange")
val numberList: List<Int> = listOf(1, 2, 3)

In this example, we have two lists: one for String values and one for Int values. <String> and <Int> are the generic type parameters, which tell the compiler what type of data each list can contain.

This feature is called compile-time type safety: the compiler detects mistakes early. If you try to add a number to stringList, the compiler will raise an error.

However, there is a catch: the detailed type information (String, Int, or another type) is not preserved when your code executes. This phenomenon is called type erasure.

Understanding Type Erasure

When Kotlin compiles your code, it removes the detailed generic type information. For example, both List<String> and List<Int> end up as list of unknown type List<*> at runtime.

Often, people say “they become List<Any>” for simplicity, but it’s more accurate to consider them as raw or wildcard types (List<?>).

While generics help you catch mistakes during compilation, they do not carry detailed type information at runtime, since that information is erased.

Type Erasure: Why does Erasure exist?

This concept of type erasure exists because of how the Java Virtual Machine (JVM), which Kotlin runs on, handles generics. It was introduced for backward compatibility with older Java code that did not use generics.

To allow newer, generic code to work with older libraries, the JVM “forgets” the specific type parameters at runtime. As a result, both List<String> and List<Int> effectively become List<?>, and the JVM no longer distinguishes between them.

The Consequences of Type Erasure

This “forgetting” has some important consequences:

  • No Runtime Type Checks: You cannot check at runtime whether a list is a List<String> or a List<Int> because that “type information” no longer exists.
  • Casting Limitations: You cannot simply cast an object to a generic type parameter (for example, “cast this object to type T) because the runtime does not know what T is.

Example of Type Erasure Limitation

fun <T> printClassName(obj: T) {
println(obj::class.java.name)
}

fun main() {
val listOfStrings: List<String> = listOf("Kotlin", "Java")
printClassName(listOfStrings) // The runtime only sees it as a List, not List<String>
}

We expect printClassName to output "List<String>". But it outputs the raw type (ArrayList or similar) because the generic type information (<String>) is lost. The function sees a generic List, not the specific List<String>.

The Solution: inline reified to the Rescue

This is where inline reified comes into play. By using these two keywords together, you can maintain type information at runtime, thus overcoming the limitations caused by type erasure.

  • inline: This keyword tells the compiler to copy (or “inline”) the entire function’s code into the location where it is called. Instead of calling a separate function, the function’s body is embedded directly. This can also improve performance in some scenarios (more on that later).
  • reified: This keyword, which can only be used with inline functions, ensures the type parameter becomes “real” (reified) at runtime. It instructs the compiler to keep type information for that parameter.

Let’s modify our previous example:

inline fun <reified T> printClassName(item: T) {
println(T::class.java.name) // Now this works!
}

fun main() {
val myList = listOf("hello", "world")
printClassName(myList) // Output: java.util.ArrayList (or similar, but the important part is...)
printClassName("hello") // Output: java.lang.String
printClassName(123) // Output: java.lang.Integer
}

Let’s Explore further

To deeply understand the mechanics and the reasons between inline and reified, I have prepared a real example that highlights the concept.

Real Scenario: Type-Safe Navigation in Android

In Android Navigation 2.8 and onward, there is built-in support for TypeSafety.

You define in a data class the parameters to pass from one screen to another. You can also pass data classes via serialization. However, to pass custom data types, you need to specify a certain NavType, which helps the system serialize and deserialize the custom type when navigating between screens.

 val PersonType = object : NavType<Person>(
isNullableAllowed = false
) {
override fun put(bundle: Bundle, key: String, value: Person) {
bundle.putParcelable(key, value)
}

override fun get(bundle: Bundle, key: String): Person? {
return if (Build.VERSION.SDK_INT < 34) {
@Suppress("DEPRECATION")
bundle.getParcelable(key)
} else {
bundle.getParcelable(key, Person::class.java)
}
}

override fun parseValue(value: String): Person {
return Json.decodeFromString<Person>(value)
}

override fun serializeAsValue(value: Person): String {
return Json.encodeToString(value)
}

override val name = Person::class.java.name
}

With this code, you define a custom NavType for the data class Person. Now you can pass a full Person object as a parameter with the new navigation component.

But notice how lengthy the code is. It works for one type, but if you have multiple types—like Person and Car—duplicating this logic can lead to boilerplate and mistakes.

We obviously need to replace our Person with a type T here to get the job done so we don’t repeat all this code if we want to pass for example a Car as well.

Attempt 1: A Generic Function (But It Fails)

We might wrap the snippet inside a function, replacing Person for a generic type T. That should give us the freedom to reuse the same logic for any Parcelable type, instead of rewriting everything for every type.

fun <T : Parcelable> NavType.Companion.mapper(): NavType<T> {
return object : NavType<T>(
isNullableAllowed = false
) {
override fun put(bundle: Bundle, key: String, value: T) {
bundle.putParcelable(key, value)
}

override fun get(bundle: Bundle, key: String): T? {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@Suppress("DEPRECATION")
bundle.getParcelable(key)
} else {
bundle.getParcelable(key, T::class.java)
}
}

override fun parseValue(value: String): T {
return Json.decodeFromString<T>(value)
}

override fun serializeAsValue(value: T): String {
return Json.encodeToString(value)
}

override val name = T::class.java.name
}

This seems perfect, but it does not compile. The problem is still type erasure. We cannot call T::class.java because the runtime does not know what T is.

Attempt 2: Introducing reified

This is where reified comes in. By adding the reified keyword before T, we tell the compiler to preserve the type at runtime.

val personType = NavType.mapper<Person>()
val carType = NavType.mapper<Car>()

fun <reified T : Parcelable> NavType.Companion.mapper(): NavType<T> {
// same code here
}

Now, T::class.java is valid. We can access T’s class at runtime because reified makes T a real type in the function. But we still get a compiler error:

‘reified’ type parameter modifier is only allowed on inline functions

Attempt 3: The Missing Piece — inline

We are almost there. The compiler insists that reified can only be used within inline functions. Why?

Here’s the key: To make reified work, the compiler needs to know the actual type of T at the place where the function is called. It can't just pass a generic T around. It needs to substitute T with User, Product, or whatever type we're using.

The inline keyword enables this by letting the compiler embed the function body directly at the call site, instead of using a traditional function call.

Think of it this way: when we call NavType.mapper<Person>(), the compiler knows T is Person. Because the function is inline, it creates a specialized version of the function, replacing T with Person throughout its body:

val personType = NavType.mapper<Person>()
val carType = NavType.mapper<Car>()

inline fun <reified T : Parcelable> NavType.Companion.mapper(): NavType<T> {
// same code here
}

This is why inline and reified must be used together. reified requires the actual type information at the call site, and inline provides the mechanism to perform that substitution.

inline reified TL;DR

Let’s summarize why reified goes hand-by-hand with inline

To maintain the type at runtime, any T marked as reified gets replaced by its concrete type at compile time, producing behind the scenes a new function in which every occurrence of T is replaced by concrete type.

The compiler then inlines this specialized version of the function at the caller site, since inline allows the function body to be copied and adapted with the concrete types. Without inline, the compiler could not generate or embed this specialized function.

inline keyword — Performance Improvements

inline reified provides type safety, but the inline keyword also offers potential performance improvements.

inline places the function’s code directly where it is called, avoiding the usual overhead of a function call (like stack operations and function lookups).

This is especially helpful when working with lambdas. When you pass a lambda to a regular function, an object is created. Inline functions avoid creating that object, which makes them much more efficient. Kotlin’s collection functions (map, filter, forEach) are inline for this exact reason: they rely heavily on lambdas.

However, inlining also has downsides. It can grow the size of your code, especially if the function is large or used in many different places (often called code bloat).

In summary, other than addressing reified issues, inlining by itself is a performance strategy, usually best for small, frequently called functions — especially those that accept lambdas. It might not be suitable for large functions or those called from many locations, since it can lead to code bloat/increased bytecode size and make your app bigger.

Controlling Inlining: `noinline` and `crossinline`

While inline offers significant advantages, Kotlin provides further control over the inlining process with two important modifiers: noinline and crossinline. These modifiers are used with lambda parameters of inline functions.

noinline — Preventing Lambda Inlining

Sometimes, you might want to use an inline function for its performance benefits (or for reified types) but not inline a specific lambda parameter. This is where noinline comes in. You mark the lambda parameter with noinline to prevent it from being inlined.

There are two primary reasons to use noinline:

1. Passing the Lambda to Another Non-Inline Function: If you need to pass the lambda to another function that is not marked inline, you must use noinline. If you try to pass an inlined lambda to a non-inline function, you’ll get a compiler error. The lambda needs to exist as a separate object to be passed.

// NON inline function
fun anotherFunction(lambda: () -> Unit) {
lambda()
}

// inline function
inline fun doSomething(first: () -> Unit, noinline second: () -> Unit) {
first() // This lambda will be inlined
anotherFunction(second) // 'second' is passed as a regular lambda object (not inlined)
}

In this example, first will be inlined as usual. second, however, is marked noinline. This prevents its code from being copied into doSomething and allows it to be passed as a function object to anotherFunction.

2. Controlling Code Size: If you have an inline function with a very large lambda, inlining that lambda repeatedly could lead to significant code bloat. Using noinline prevents this.

inline fun processData(data: List<String>, noinline largeProcessingLogic: (String) -> Unit) {
for (item in data) {
// Some small, frequently executed code that benefits from inlining
if (item.isNotEmpty()) {
largeProcessingLogic(item)
}
}
}

fun main() {
val data = listOf("a", "b", "", "c", "d", "", "e")

processData(data) { item ->
// Imagine this is a VERY large lambda, with hundreds of lines
// of complex logic, database calls, network requests, etc.
}
}

Without noinline, the entire largeProcessingLogic lambda would be copied into the loop for each call to processData, drastically increasing the bytecode size. With noinline, only a reference to the lambda is passed, avoiding code duplication.

crossinline — Managing Non-Local Returns

crossinline helps you control how the return keyword works inside lambdas passed to inline functions. It makes return behave in a more predictable way.

  • Normal return (in regular functions and non-inline lambdas): A return statement exits only the lambda or function it’s directly inside.
  • Non-Local return (in inline functions, without crossinline): A return statement inside an inlined lambda exits the function that called the inline function. This can be surprising!
  • Local return (with crossinline): crossinline prevents non-local returns. A return inside a crossinline lambda will only exit the lambda itself, just like a normal return.
// Example 1: Non-Local Return (without crossinline)
inline fun doSomething(action: () -> Unit) {
println("Start doSomething")
action()
println("End doSomething") // This might NOT be printed
}

fun test1() {
doSomething {
println("Inside lambda")
return // This exits test1(), NOT just the lambda!
}
println("This will NOT be printed")
}

// Example 2: Local Return (with crossinline)
inline fun doSomethingSafely(crossinline action: () -> Unit) {
println("Start doSomethingSafely")
action()
println("End doSomethingSafely") // This WILL be printed
}

fun test2() {
doSomethingSafely {
println("Inside lambda")
return // This exits ONLY the lambda
}
println("This WILL be printed")
}

fun main() {
println("Running test1:")
test1() // Output: Start doSomething, Inside lambda
println("\nRunning test2:")
test2() // Output: Start doSomethingSafely, Inside lambda, End doSomethingSafely, This WILL be printed
}
  • test1: The return inside the lambda exits test1 completely. "End doSomething" and "This will NOT be printed" are never reached.
  • test2: The crossinline keyword forces the return to be local. It only exits the lambda, not test2. "End doSomethingSafely" and "This WILL be printed" are executed.

Compiler Error: If you try to pass a lambda marked with crossinline to another lambda or anonymous object, you will get a compiler error.

Use crossinline when:

  • You want the return in a lambda to behave like a normal return (exiting only the lambda).
  • You are making a library, and you want to make sure users of your inline function don't accidentally use non-local returns, which could change the flow of their program in unexpected ways.

Summary of Modifiers

  • inline: Copies the function's code and lambda code (by default) to the place where it's called. Allows return in a lambda to exit the calling function (non-local return).
  • noinline: Prevents a specific lambda parameter from being inlined. Necessary for passing lambdas to non-inline functions.
  • crossinline: Allows a lambda to be copied (inlined), but forces return to only exit the lambda itself (local return).

Limitations and When Reflection is Necessary

inline reified is a powerful tool, but it’s not a universal solution. There are specific situations where it cannot be used. In these cases, reflection (or other, less common workarounds) becomes necessary.

Here are some key scenarios where inline reified cannot be used:

  1. Dynamic Type Discovery: If you truly don’t know the type of an object until runtime, you can’t use inline reified. reified requires the type to be known where you call the inline function. For example, if you’re reading data from a file and the file format dictates the data type, you won’t know the type until you’ve read the file. Reflection would be needed here.
  2. Interacting with Non-Kotlin Code (Without Known Types): If you’re calling Java code (or code from other JVM languages) that doesn’t provide generic type information in a way that Kotlin can understand, you might not be able to use `reified`. You might need to use reflection to interact with the returned objects.
  3. Interface method with default implementation: If the method with reified is declared inside an Interface with default implementation.
  4. Recursive inline functions: You can’t use inline with functions that call themselves ("recursive" functions). The computer copies the function's code each time it's used. If a function calls itself, this copying would never stop.
  5. Accessing Non-Public Members (Without @PublishedApi): inline functions can’t directly access non-public members of a class unless those members are marked with the @PublishedApi annotation.
  6. Variable Type Arguments: You cannot use a variable as a type argument for a reified type.
inline fun <reified T> myFun() {
println(T::class.simpleName)
}

fun <T> otherFun() {
myFun<T>() // Compilation error
}

In the code above, we are trying to use the type variable T as a type argument. But this is not allowed.

Reflection: The Alternative (and Its Costs)

When inline reified isn’t an option, reflection is often the fallback. Reflection allows a program to inspect and manipulate the structure and behavior of objects at runtime. This includes examining types, accessing fields, and invoking methods, even if those elements are not known at compile time.

However, this power comes at a cost:

  • Speed: Reflection is significantly slower than direct type access. It involves dynamic lookups and checks that add overhead.
  • Type Safety: Reflection doesn’t check types when you write your code. This means you might get errors when your program runs that you would normally find earlier.
  • Code Complication: Code that uses reflection is often longer and harder to understand.

Reflection can do some of the same things as inline reified, but it's usually slower, less safe, and harder to use. inline reified is the best way to do this in most cases where you need to know the type while the program is running.

Another Alternative: KClass

Another approach, particularly when you just need to know the Class of a type, is to pass a KClass object as an argument to your function. KClass is Kotlin’s representation of a class.

It’s like a blueprint of the class, providing information about its properties, functions, and constructors. For example:

fun <T : Any> printClassName(obj: T, clazz: KClass<T>) {
println("The class name is: ${clazz.simpleName}")
}

fun main() {
printClassName("Hello", String::class) // Pass String::class
printClassName(123, Int::class) // Pass Int::class
}

This works, but it adds extra code, you have to explicitly pass the KClass every time you call the function. inline reified avoids this extra step because the compiler automatically inserts the type information.

It’s important to understand that KClass is not a complete replacement for inline reified. KClass provides information about a type, while inline reified allows you to use a type as if it were concrete, even in the presence of type erasure.

They serve different, though related, purposes. inline reified gives you more power within the function where it's used.

Conclusion

inline reified is a powerful feature in Kotlin that solves the problem of type erasure while providing performance advantages.

By using inline and reified together, you can write code that is generic, type-safe, and efficient. It avoids repetitive code (as in our Android Navigation example) and removes the reflection overhead otherwise needed to access runtime type information.

Learning how to use inline reified effectively helps you create cleaner, more maintainable, and faster Kotlin code, whether you are developing Android apps, managing complex data, or working with Java libraries.

It is an essential tool for improving the quality and performance of your Kotlin projects.

Further Reading

Kotlin Official Documentation

  • Inline functions: The official Kotlin documentation explaining inline functions, performance benefits, and how they work with lambdas and noinline/crossinline.
  • Reified type parameters: Specifically focuses on reified type parameters within inline functions, detailing how they address type erasure.
  • Generics: A comprehensive overview of generics in Kotlin, essential for understanding the context of type erasure and inline reified.

Blog

--

--

Responses (2)

Write a response