Kotlin Variance Demystified: Understanding Generics & Subtyping

Table of Contents

Kotlin is a powerful, modern programming language with robust support for generics. But when dealing with generics, you often run into the concept of variance, which can be tricky to grasp at first. In this post, we’ll break down Kotlin variance in a simple and engaging way, ensuring you fully understand generics and subtyping.

What is Kotlin Variance?

Variance defines how generic types relate to each other in the context of subtyping. Consider this:

Kotlin
open class Animal
class Dog : Animal()

In normal inheritance, Dog is a subtype of Animal. But does List<Dog> automatically become a subtype of List<Animal>? The answer is no, unless we explicitly declare variance.

Kotlin provides three ways to handle variance:

  • Covariance (out)
  • Contravariance (in)
  • Invariance (default behavior)

Let’s explore each of these concepts with examples.

Covariance (out) – Producer

Covariant types allow a generic type to be a subtype of another generic type when its type parameter is only used as an output (producer). It is declared using the out keyword.

Kotlin
interface Producer<out T> {
    fun produce(): T
}

This means that Producer<Dog> can be used where Producer<Animal> is expected:

Kotlin
class DogProducer : Producer<Dog> {
    override fun produce(): Dog = Dog()
}

val producer: Producer<Animal> = DogProducer() // Works fine

Why Use out?

  • It ensures type safety.
  • Used when a class only returns values of type T.
  • Common in collections like List<T>, which can only produce items, not modify them.

Example:

Kotlin
fun feedAnimals(producer: Producer<Animal>) {
    val animal: Animal = producer.produce()
    println("Feeding ${animal::class.simpleName}") //O/P - Feeding Dog
}

Since Producer<Dog> is a subtype of Producer<Animal>, this works without any issues.

Contravariance (in) – Consumer

Contravariant types allow a generic type to be a supertype of another generic type when its type parameter is only used as an input (consumer). It is declared using the in keyword.

Kotlin
interface Consumer<in T> {
    fun consume(item: T)
}

This means that Consumer<Animal> can be used where Consumer<Dog> is expected:

Kotlin
class AnimalConsumer : Consumer<Animal> {
    override fun consume(item: Animal) {
        println("Consuming ${item::class.simpleName}")
    }
}

val consumer: Consumer<Dog> = AnimalConsumer() // Works fine

Why Use in?

  • Again it ensures type safety.
  • Used when a class only takes in values of type T.
  • Common in function parameters, like Comparator<T>.

Example:

Kotlin
fun addDogsToShelter(consumer: Consumer<Dog>) {
    consumer.consume(Dog())
}

Since Consumer<Animal> is a supertype of Consumer<Dog>, this works perfectly.

Invariance (Default Behavior: No in or out)

By default, generics in Kotlin are invariant, meaning Box<Dog> is NOT a subtype or supertype of Box<Animal>, even though Dog is a subtype of Animal. Means, they do not support substitution for subtypes or supertypes.

Kotlin
class Box<T>(val item: T)

val dogBox: Box<Dog> = Box(Dog())
val animalBox: Box<Animal> = dogBox // Error: Type mismatch

Why?

Without explicit variance, Kotlin prevents unsafe assignments. If variance is not declared, Kotlin assumes that T can be both produced and consumed, making it unsafe to assume subtyping.

Star Projection (*) – When Type is Unknown

Sometimes, you don’t know the exact type parameter but still need to work with a generic class. Kotlin provides star projection (*) to handle such cases.

Example:

Kotlin
fun printList(list: List<*>) {
    for (item in list) {
        println(item) // Treated as Any?
    }
}

A List<*> means it could be List<Any>, List<String>, List<Int>, etc., but we cannot modify it because we don’t know the exact type.

Best Practices for Kotlin Variance

  • Use out when the type is only produced (e.g., List<T>).
  • Use in when the type is only consumed (e.g., Comparator<T>).
  • Keep generics invariant unless variance is required.
  • Use star projections (*) when you don’t know the type but need read access.

Conclusion

Understanding Kotlin Variance is crucial for writing flexible and type-safe code. Covariance (out) is used for producers, contravariance (in) for consumers, and invariance when both roles exist. By mastering Kotlin Variance concepts, you can work effectively with generics and subtyping in Kotlin.

Skill Up: Software & AI Updates!

Receive our latest insights and updates directly to your inbox

Related Posts

error: Content is protected !!