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
in
,out
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 Sword
, Sheath<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: FireScroll
, IceScroll
, 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
is
, as
, 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
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 toList<out Any?>
. You can safely read valuesAny?
. - For contravariant types like
Comparator<in T>
,Comparator<*>
is equivalent toComparator<in Nothing>
. You can’t safely write anything in (sinceNothing
has no instances). - For invariant types like
MutableList<T>
,MutableList<*>
is equivalent toMutableList<out T>
for reading values, and toMutableList<in Nothing>
for writing values.
4. Variance Cheat Sheet & PECS

Producer →
out, Extends / Consumer →
in, Super
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 in
, out
, reified
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
- https://kotlinlang.org/docs/generics.html
- https://kotlinlang.org/docs/inline-functions.html
- https://www.baeldung.com/java-generics-pecs
- All images are generated by GPT-4o
This article was previously published on proandroiddev.com.