Blog Infos
Author
Published
Topics
, , , ,
Published

Understanding Generics in Kotlin can be tough for beginners; trust me, I’ve been there. Let me tell you a little fantasy story to understand them easily.

After reading this, you will be able to :

  • Understand how covariant, contravariant, and invariant types work(with inout keywords).
  • Understand that Kotlin erases type information at runtime and how reified keyword works.
  • Learn use-site variance, star-projections, and null safety in Generics.
  • Discover how Generics are applied in the real world(in Kotlin APIs).
Once Upon a Type…

Note: The story has some embellishments to fit the narrative.

 

In the land of Generics, three Android heroes — Warrior, Mage, and Archer — embarked on a quest to defeat the legendary Lord Ambiguous, who threatened the land of Generics.

They had to pass through the Forbidden Forest to reach the Lord Ambiguous, which taught them the variance rules in the land of Generics.

Table of Contents
  • Chapter 1: The Mystic Gate and Invariance
  • Chapter 2: The Magical Scroll and Covariance
  • Chapter 3: The Lord Ambiguous and Contravariance
  • Chapter 4: The Final Battle and Type Erasure
  • Chapter 5: The Spell of Reified
  • Chapter 6: The Ending
  • Bonus Chapter: Advanced Kotlin Generics
Chapter 1: The Mystic Gate and Invariance

 

At the entrance of the Forbidden Forest stood the Mystic Gate.
A stern gatekeeper materialized. “Halt! If you are a warrior, only those who carry Sheath<Sword> may enter.”

The warrior confidently held a Sheath<Excalibur>, but despite Excalibur being a subtype of Sword, the gatekeeper refused to let them pass.

open class Sword
class Excalibur : Sword()

class Sheath<T>(val item: T)

val sword: Sheath<Sword> = Sheath<Excalibur>(Excalibur()) // ❌ Compile Error: Type mismatch!
val excalibur: Sheath<Excalibur> = Sheath<Sword>(Sword()) // ❌ Compile Error: Type mismatch!

The rule was clear. Even if Excalibur is a SwordSheath<Excalibur> is not a subtype of Sheath<Sword>(and vice versa).

Explanation — Variance and Invariance

In Kotlin, variance defines how subtyping between complex types relates to subtyping between their type parameters.

Generic types are invariant by default(written simply as <T>), which means the type parameter must match exactly. No substitutions with subtypes or supertypes are allowed for the generic type itself.

That’s because invariance allows you to read/write. If Kotlin allowed you to treat Sheath<Excalibur> as Sheath<Sword>, you might read an Excalibur safely, but attempting to write a basic Sword back into it would break type safety (as the variable expects a Sheath<Excalibur>). Invariance prevents potential conflicts by ensuring an exact type match, thus ensuring the safety of both read and write operations.

// ✅ Invariance allows you to read/write
class Sheath<T>(private var item: T) {
    fun get(): T = item
    fun replace(newItem: T) {
        item = newItem
    }
}
Chapter 2: The Magical Scroll and Covariance

 

After passing through the Mystic Gate with some refactoring, the heroes found a hidden Knowledge Vault full of magical bookshelves.

Mage approached a magical bookshelf BookShelf<out T>.
She could read all kinds of scrolls: FireScrollIceScroll, and so on.

But every time she tried to add a new scroll… the bookshelf refused.

open class Scroll
class FireScroll : Scroll()
class IceScroll : Scroll()

class Bookshelf<out T>(val scroll: T) {
    fun read(): T = scroll // ✅
    // fun add(newScroll: T) { ... } // ❌ Compile error: T is declared as 'out'
}

val fireShelf: BookShelf<FireScroll> = BookShelf(FireScroll())
val generalShelf: BookShelf<Scroll> = fireShelf // ✅

The vault was covariant. It allowed her to safely read more specific types within a general type, but didn’t allow for any mutable operations.

Explanation — Covariance with ‘out’

Covariance, declared with the out keyword (out T), means you can safely read values of type T, but you cannot write to it(read-only). This is because the actual object might be a subtype of T, and writing something of type T might break type safety.

This explains why List<out E> from Kotlin Collections can read elements of type T from the list, but adding elements is not allowed, since the list might internally be holding a more specific type than T.

// Declaration
public interface List<out E> : Collection<E> { ... }

// Usage
val children: List<Child> = listOf(Child())
val parents: List<Parent> = children // ✅

While MutableList<E> is invariant, read/write access is supported, so no substitutions with subtypes or supertypes are allowed.

// Declaration
public interface MutableList<E> : List<E>, MutableCollection<E> { ... }

// Usage
val children: MutableList<Child> = mutableListOf(Child())
// val parents: MutableList<Parent> = children // ❌ Compile error: Type mismatch.
Chapter 3: The Lord Ambiguous and Contravariance

 

At last, the heroes reached the Lord Ambiguous.

No one knew what kind of damage would be most effective. The Lord Ambiguous revealed nothing; his weakness remained a mystery. But he could take damage. Any kind.

Each hero tried what they could:

  • The warrior attacked with Damage
  • The mage unleashed FireDamage
  • The archer struck with ArrowDamage

And the Lord Ambiguous… took all the damages.

open class Damage
class FireDamage : Damage()
class ArrowDamage : Damage()

class Enemy<in T> {
    fun takeHit(damage: T) {
        println("The enemy took damage: $damage")
    }
    // fun getWeakness(): T { } // ❌ Compile error: T is declared as 'in'
}

val enemy = Enemy<Damage>()
enemy.takeHit(Damage()) // ✅
enemy.takeHit(FireDamage()) // ✅
enemy.takeHit(ArrowDamage()) // ✅

With the Enemy<in T> type, the heroes could put in many forms of attack, but could never inspect the actual type.

Explanation — Contravariance with ‘in’

With contravariance, expressed with the in keyword (in T), you can safely write values of type T, but you cannot read from it. This is useful when you’re passing data into a generic type, like a consumer. Kotlin allows you to pass a T value into the structure because any supertype of can safely accept it T, but reading from it is unsafe since the actual contents might be of a more general type.

This explains why Comparable<in T> in Kotlin can safely write values of type T, but does not involve reading aT value.

// Declaration
public interface Comparable<in T> {
    public operator fun compareTo(other: T): Int
}

Since Comparable only needs to accept values to compare against, we don’t need to know the exact type inside — we just need to be sure we can safely pass a T. This makes it a perfect case for contravariant design with in T.

Chapter 4: The Final Battle and Type Erasure

 

The Lord Ambiguous roared in fury. Despite all their knowledge of variance, despite every type-safe strike… it still stood.
“You know how to attack,” it growled, “but do you truly know what I am?”

Then the Mage recalled something she read in the Knowledge Vault:
“In this land of Generics, much of the specific type information is lost after the initial checks. It’s called… Type Erasure.”

That meant they couldn’t check the enemy’s actual type at runtime. During the runtime, everything specific often just looked like * — a star-projection, representing some type, but unknown.

Explanation — Runtime Type Erasure

In Kotlin(as in Java), generic type arguments are erased at runtime. This means that although you may write and interact with a List<Int> or List<String> at compile time, at runtime, both types are simply treated as List<*>.

This process, known as the Type Erasure, does not retain generic type information in its type system at runtime. This happens mainly for historical reasons(ensuring compatibility with older Java versions that didn’t have generics), and to avoid potential runtime performance overhead associated with carrying detailed type information for every generic instance.

Note: Some type information can be retained in specific contexts, like superclass tokens or reflection on class definitions themselves. Please refer to more: https://www.baeldung.com/java-super-type-tokens

Consequently, runtime type safety cannot distinguish between generic instantiations like List<Int> and List<String>.

// ❌ Runtime error: Both constructor have the same JVM signature
class Foo(val ints: List<Int>) {
    constructor(strings: List<String>) : this(strings.map { it.toInt() })
}

But due to type erasure, both constructors are treated to have the same JVM method signature, and it error will occur at runtime. (To solve constructor/function signature clashes like this, factory functions or using @JvmName might help. Please refer to more: https://kt.academy/article/ek-factory-functions)

Note: Type erasure does not mean generics are useless! It’s only after these strict compile-time checks pass that the specific type information is typically erased, making it unavailable for inspection at runtime.

Chapter 5: The Spell of Reified

 

Since the generic type arguments are erased at runtime, the heroes couldn’t guess the proper weakness of the Lord Ambiguous.

fun <T : Damage> isWeakTo(): Boolean {
    // ❌ Compile error: Cannot check for instance of erased type: T
    // return T is hiddenWeakness 
}

But Mage knew one ancient technique, an inline spell with reified type that preserved type information even in runtime.

She cast:

inline fun <reified T : Damage> isWeakTo(): Boolean {
    return hiddenWeakness is T // ✅ Check becomes possible at runtime
}

val weakToFire = isWeakTo<FireDamage>()
println("Is weak to FireDamage? $weakToFire") // Output: false
val weakToIce = isWeakTo<IceDamage>()
println("Is weak to IceDamage? $weakToIce") // Output: true

For once, the spell whispered back the truth at runtime.
The Lord Ambiguous was vulnerable to IceDamage!

Explanation — Inline Reified Type Parameters

Normally, as discussed with Type Erasure, you lose access to the specific generic type argument T at runtime. Trying to check hiddenWeakness is T results in a compile error because T’s specific type information isn’t preserved. But if you use reified with an inline function, Kotlin allows you to use isas, and even T::class safely at runtime.

When you mark a function with inline, the compiler doesn’t just generate a standard function call. Instead, it copies the bytecode of the inline function directly into the location where the function is called. This can sometimes improve performance, especially with lambda arguments, by avoiding the overhead of creating function objects.

With inline +reified keyword, the function’s code is being copied directly to the call site, the compiler knows the actual type argument being used at that specific call site. Within the context of that specific inline function call, the type information is not erased, making it “reified”. This allows you to perform runtime operations that are normally forbidden for generic types:

  • Type checks: value is T
  • Type casts: value as T
  • Accessing the type’s KClass: T::class (e.g., T::class.java)

Note: This only works because the code is inlined. Also means reified type parameters can only be used with inline functions. Please refer to more:
https://kotlinlang.org/docs/inline-functions.html#reified-type-parameters

This also explains why operations like filterIsInstance from Kotlin Collections, can keep elements of type T with no reflection needed:

// Declaration
inline fun <reified R> Iterable<*>.filterIsInstance(): List<...> {...}

// Usage
val list = listOf(1, "2", 3.0)
val ints = list.filterIsInstance<Int>()  // [1]

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Kobweb:Creating websites in Kotlin leveraging Compose HTML

Kobweb is a Kotlin web framework that aims to make web development enjoyable by building on top of Compose HTML and drawing inspiration from Jetpack Compose.
Watch Video

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kobweb

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author o ...

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kob ...

Jobs

Chapter 6: The Ending

As soon as the Mage attacked with IceDamage, The Lord Ambiguous screamed. His type-shield shattered, no longer protected by generic ambiguity.

The classic ending of the fantasy fictions.

The Lord Ambiguous was finally defeated! Not by brute force, but by understanding how types worked in their deepest form. The land was saved. And the heroes? They were type-safe(not ambiguous for sure!).

And the heroes in the land of Generics lived happily ever after! 🎉

Bonus Chapter: Mastering the Deeper Magic

While the main storyline ended on a positive note, the land of Generics contains more magical forces. Let’s discover more!

1. Use-Site Variance

Remember how we declared variance on the BookShelf(out T) and Enemy(in T) above? That’s declaration-site variance — the variance rule is fixed in the class definition. However, there are occasions you may prefer to work with classes that are invariant (like MutableList<E>), but you only need to use them in a covariant or contravariant way in a specific function or context.

Look at the example below; while copying from a list, no write operations are needed in the list from.

fun copy(from: MutableList<out Any>, to: MutableList<Any>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

To prohibit writing to from , simply use the out keyword, which performs type projection. It means that from is a restricted list. This can temporarily make a normally read/write generic type behave as read-only (out) or write-only (in) for that specific use.

2. Null Safety in Generics

A standard generic type parameter T has an implicit upper bound of Any?. Which means T itself can represent a nullable type!

That’s why List<T> can be instantiated as List<String?>.

val list: List<String?> = listOf("null", null)

So it’s important to enforce non-null types by using the constraint if you need to guarantee that the type argument provided for T is non-nullable.

We can set boundaries for T to have specific capabilities, and T : Any ensures that the type argument itself cannot be nullable.

class NonNullGeneric<T : Any>

// val n = NonNullGeneric<String?>() // ❌ Compile error: Not within its bounds

It’s also possible to make non-nullable function type arguments that have a nullable class generic type.

class NullableGeneric<T> {
    fun nonNullOperation(t: T & Any) { ... }
}

val n = NullableGeneric<String?>() // ✅
// n.nonNullOperation(null) // ❌ Compile error: receives non-null type String

The most common use case for declaring non-nullable types is when you want to override a Java method that contains @NotNull as an argument.

Note: It’s possible to set multiple bounds using where when T needs to satisfy multiple conditions. Please refer to more: https://kotlinlang.org/docs/generics.html#upper-bounds

3. Star-projections

Star-projection(*) provides a type-safe way to handle generic types when the specific type argument is unknown, allowing safe read access as Any?.

val mysterySpells: List<*> = listOf(...)

This is Kotlin’s safe wildcard, like Java’s raw types, but safer.

  • For covariant types like List<out T>List<*> is equivalent to List<out Any?>. You can safely read values Any?.
  • For contravariant types like Comparator<in T>Comparator<*> is equivalent to Comparator<in Nothing>. You can’t safely write anything in (since Nothing has no instances).
  • For invariant types like MutableList<T>MutableList<*> is equivalent to MutableList<out T> for reading values, and to MutableList<in Nothing> for writing values.
4. Variance Cheat Sheet & PECS
This concept is also known as PECS.
Producer → outExtends / Consumer → inSuper

Producer (out) corresponds to ‘Extends’ in PECS, meaning you can get items out (read). Consumer (in) corresponds to ‘Super’ in PECS, meaning you can put items in (write/consume). (Please refer to more: https://www.baeldung.com/java-generics-pecs)

Conclusion

And that’s it!

I found Kotlin Generics to be a bit challenging when I first started learning the language). But by understanding how keywords like inoutreified work and how they were designed, you’re not just learning syntax; you’re taking a step to write more type-safe code.

Hope our journey through the fantasy land of Generics with our heroes has made these concepts easier to understand! 🏞️

References

This article was previously published on proandroiddev.com.

Menu