Mastering Kotlin Generics: Write Safer, Smarter Code

A practical guide to variance, reified types, and type-safe design.

Visual summary of key concepts in Kotlin Generics

What You’ll learn

  • How Kotlin generics improve code safety and reuse.
  • Learn the difference between inout, and invariant types.
  • How reified solves runtime type erasure in inline functions.
  • What variance means and how it affects type relationships
  • The difference between subclasses vs subtypes

Why Generics?

Generics are a powerful feature that let you write safer, more reusable and maintainable code. By allowing classes, functions, and interfaces to work with different types while preserving type safety, generics help eliminate the boilerplate and runtime casting errors.
Kotlin’s standard collections make heavy use of generics. Think about it: if we couldn’t define the type of items in a List, it would be a nightmare —  we’d constantly be doing dangerous type casts.

Another important issue Kotlin generics solves is variance. Consider these two classes:

interface Animal { 
    fun sound()
}

class Dog : Animal {
    override fun sound() {
        println("Bark")
    }
}

class Cat : Animal {
    override fun sound() {
        println("Meow")
    }
}

At first glance, you might assume that List<Dog> is a sub type of List<Animal>. However, this is only true because List interface explicitly declares its generic type as covariant using out keyword. Without this, the Kotlin compiler would treat List<Dog> and List<Animal> as unrelated types.
Just to clarify — we’re talking about subtypes here, not subclasses. They’re different concepts, and we’ll dive into that soon.

Deeply understanding generics will significantly improve your Kotlin code’s readability, safety and reusability.

Generic Type Parameters

Generic type parameters <T> act as placeholders for actual types, allowing us to write reusable and type safe code. Every time an instance of a generic type is created, type parameters are replaced with specific types (known as type arguments).

We can define generics in either classes, interfaces, or functions.

Consider a simple generic class Farm and a global generic function feed()

class Farm<T> {
    private val animals: MutableList<T> = mutableListOf()

    fun addAnimal(animal: T) {
        animals.add(animal)
    }

    fun getAnimals(): List<T> {
        return animals
    }
}

fun <T> feed(animal: T) { ... }

Now, this is not very useful because the type parameter is not restricted, we can pass anything and it will compile. Furthermore, inside the feed() function we can’t call Animal’s sound() method because it is not constrained to be an animal, which leads us to type constraints.

Type Constraints

Constraints let us restrict the types that can be replaced as type arguments for a class or function.

We can define an upper bound constraint for a generic type parameter, and the corresponding type arguments during instantiation must be either the specified type or a subtype:

class Farm<T: Animal> {
 ...
}

Now, Farm ‘s generic type can only be an object implementing the Animal interface, allowing us to safely access properties or methods defined in Animal. For example:

fun <T: Animal> feed(animal: T) {
    animal.sound()
}

Attempting to pass a type that doesn’t conform to this constraint will result in a compile-time error:

feed("Not an animal") // ❌ Compile-time error

By default, if no upper bound is specified, Kotlin assumes the upper bound to be Any?, allowing nullable values. If we want to explicitly disallow null values, we can set the upper bound as Any, ensuring that the generic type T is always non-null.

Finally, remember that type parameters can be either provided or inferred by the compiler:

val farm = listOf(Dog) // inferred type: List<Dog>
val farm = listOf(Dog(), Cat()) // inferred type: List<Animal>
val farm: List<Animal> = listOf(Dog()) // explicit type

Type constraints are especially useful when building libraries or APIs where you want to restrict generic behavior to certain capabilities (like Comparable andNumber)

Reified and erased type parameters

At runtime, generics are erased. This means that at runtime we can’t know which type arguments were defined to create the instance. For example, if we create a List<String> , at runtime we will not be able to identify the type of the elements contained within that list, we can only know it is a list of some unknown type.

The only one that can distinct List<String> from List<Animal> is the compiler, the runtime is not able to. You can easily check this by trying to compile the following code:

if (list is List<String>) { } // ❌ Cannot check for instance of erased type: List<String>

if (list is List<*>) // ✅ Valid using Star Projection, there is a specific type but we don't know it

The same limitation applies to generic functions:

fun <T> runIfIsType(value: Any, block: () -> Unit) {
    if (value is T) { // ❌ Cannot check for instance of erased type: T
        block()
    }
}

Luckily, reified type parameters comes to the rescue by helping with this limitation in a very particular case: inline functions. The following code compiles:

inline fun <reified T> runIfIsType(value: Any, block: () -> Unit) {
    if (value is T) {
        block()
    }
}

This works because inline functions are inserted in the place where they are called. Thus, the compiler can know the exact type used as the type argument in that particular call. This is the only case where we can use reified types.

Variance: subtyping

Previously, we said that List<Dog> is a sub type of List<Animal> because List declares its generic type as covariant, let’s try to explain what this means.

What problems variance solves? well, what if we have a function that receives a MutableList<Any>, is it safe to pass a MutableList<String> ?
It is not, the compiler will not allow us to do that because it would crash at runtime:

fun varianceExample1() {
    val stringsList = mutableListOf("Hello", "World")

    addIntToList(stringsList) // ❌ Compiler error: Type mismatch.
    println(stringsList)
}

fun addIntToList(list: MutableList<Any>) {
    list.add(1) // ❌ potential Runtime Exception
}

What if instead of a Mutable list we haveList<String> and List<Any> ?

fun varianceExample2() {
    val stringsList: List<String> = mutableListOf("Hello", "World")

    printList(stringsList) // ❌ Compiler error: Type mismatch.
}

fun printList(list: List<Any>) {
    println(list)
}

Since the function that receives the list only reads from it and doesn’t modify it, it is completely safe. Note that it matters how a generic type is used: is it just receiving and reading a value or is it modifying it? Variance defines the relationship between generics

Subclass vs Subtype

These two relations are often confused or used interchangeably, but strictly speaking they are not the same.

A type B is a subtype of a type A if you can use the value of the type B whenever a value of the type A is required.

In other words: If x: B, then x can be used in any context where A is expected. This is strongly related to Liskov substitution principle, one of the SOLID principles.

So, it is obvious that a subclass B is a subtype of its superclass A if no generics are involved, but things change when generics come to the scene. The subtyping relationship is determined by the variance on its generic type parameters.

Covariance

A covariant class is a generic class where the following is true:

Farm<A> is a subtype of Farm<B> if A is a subtype of B.

In Kotlin, to define a covariant class we use the out keyword:

class Farm<out T: Animal>(private val animals: List<Animal>) {

    fun feedAll() {
       for (animal in animals) {
           animal.feed()
       }
    }
}

By defining the class Farm as covariant for its type T , we can (for instance) pass a Farm<Cat> to any place that requires a Farm<Animal>:

fun visitFarm(farm: Farm<Animal>) {
    farm.feedAll()
}

val catFarm: Farm<Cat> = Farm(listOf(Cat()))
// This is valid only because T was defined as covariant
visitFarm(catFarm)

But defining a type parameter as covariant is not free: it adds a limitation on how the generic class can use that type. In the case of covariance, this means that the type can only be used in the out/producer position in any of its externally visible methods (public, protected, internal)
To clarify: in public/protected/internal methods, T can’t be a parameter and it can only be returned.

class Farm<out T : Animal>(private val animals: MutableList<T>) {

    fun feedAll() {
        for (animal in animals) {
            animal.feed()
        }
    }

   operator fun get(i: Int): T { // ✅ 
        return animals[i]
    }

    fun remove(animal: T) { // ❌ Type parameter T is declared as 'out' but occurs in 'in' position in type T
        animals.remove(animal)
    }

   private fun stroke(animal: T) { // ✅ OK because this method is private
        
    }
}

Private methods are not affected by variance limitations because variance rules are designed to protect the class from incorrect usage by external callers. Since private methods aren’t accessible outside the class, the compiler allows more flexibility inside them.

Contravariance

Can be thought of as the opposite of covariance: the subtyping relationship of the generic class is reversed compared to the subtyping of its type parameters. 🤯

To clarify: if we define Farm as contravariant on T , then Farm<Animal> is a subtype of Farm<Cat> (if Cat is a subtype of Animal )

Contravariance on a type is declared by using the in keyword. This also limits how we can use the T type in our class: Tcan only be in in positions of externally visible methods.

class AnimalFeeder<in T: Animal> {
    fun feed(animal: T) {
        println("Feeding $animal")
    }
}

fun visitDogFeeder(feeder: AnimalFeeder<Dog>) {
   // ...
}

val animalFeeder = AnimalFeeder<Animal>()

visitDogFeeder(animalFeeder) // ✅ Allowed: contravariant

Note: A class can be covariant on one type and contravariant on another. 

Summary

  • At runtime, generic type parameters are erased. We can’t know the exact type of items contained in a List<>
  • The previous limitation can only be avoided using inline functions.
  • Variance defines the subtyping relationship in generic type parameters. For instance: is Farm<B> a subtype of Farm<A> ?
  • out keyword means CovariantT can only be used as a return type in public methods
  • in keyword means Contravariant: T can only be used as a parameter in public methods
  • By default generic type parameters are Invariant: meaning that Farm<A> and Farm<B> are completely different types, even if B is a subtype of A