Variance (in/out) in Kotlin Programming Language

Categories:
4 minute read
Introduction
Variance is a crucial concept in Kotlin’s type system that helps manage subtyping relationships in generic types. Understanding variance is essential for writing robust and type-safe code, especially when working with collections, function parameters, and return types.
Kotlin introduces two keywords—in
and out
—to define how generic type parameters behave in terms of subtyping. These keywords allow for more flexible and safe use of generics compared to Java’s wildcard types.
In this blog post, we’ll explore:
- What variance is
- Covariance (
out
keyword) - Contravariance (
in
keyword) - How variance affects function parameters and return types
- Real-world examples and best practices
Understanding Variance
Variance determines how a generic type relates to its subtypes. In simpler terms, it defines whether a generic class or function can accept subtypes (out
) or supertypes (in
).
Consider this example:
open class Animal
class Dog : Animal()
class Cat : Animal()
If List<Dog>
were a subtype of List<Animal>
, we could safely pass a list of dogs where a list of animals is expected. However, mutability in collections makes this tricky because allowing modifications could lead to type safety issues.
To manage such scenarios, Kotlin provides two variance modifiers: out
and in
.
Covariance (out
Keyword)
Covariance allows a generic type to be substitutable for a supertype. In Kotlin, we declare a type as covariant using the out
modifier.
Example
interface Producer<out T> {
fun produce(): T
}
Here, T
is used only as a return type (produced value). The out
modifier means Producer<Dog>
is a subtype of Producer<Animal>
, making it safe to use in a broader context.
Why Covariance Works
A covariant type parameter is read-only—it can only be used as an output. If Kotlin allowed modifications, type safety issues could arise. Example:
fun feedAnimals(producer: Producer<Animal>) {
val animal: Animal = producer.produce()
}
val dogProducer: Producer<Dog> = object : Producer<Dog> {
override fun produce(): Dog = Dog()
}
feedAnimals(dogProducer) // Allowed because Producer<Dog> is a subtype of Producer<Animal>
Since Dog
is a subtype of Animal
, it is safe to use Producer<Dog>
wherever Producer<Animal>
is required.
Contravariance (in
Keyword)
Contravariance works in the opposite way—allowing a generic type to accept supertypes. This is useful when dealing with consumers that take values but don’t return them.
Example
interface Consumer<in T> {
fun consume(item: T)
}
Here, T
is used only as an input parameter. The in
modifier means Consumer<Animal>
is a subtype of Consumer<Dog>
, allowing more flexible assignments.
Why Contravariance Works
A contravariant type parameter is write-only—it can only be used as an input, ensuring type safety.
fun trainDogs(trainer: Consumer<Dog>) {
trainer.consume(Dog())
}
val animalTrainer: Consumer<Animal> = object : Consumer<Animal> {
override fun consume(item: Animal) {
println("Training an animal: ${item::class.simpleName}")
}
}
trainDogs(animalTrainer) // Allowed because Consumer<Animal> is a supertype of Consumer<Dog>
Since Animal
is a broader type than Dog
, it is safe to use Consumer<Animal>
where Consumer<Dog>
is expected.
Function Parameter and Return Type Variance
Kotlin’s function types also follow variance rules:
- Function return types are covariant (
out
). - Function parameter types are contravariant (
in
).
Example:
val producer: () -> Animal = { Dog() } // Covariant return type
val consumer: (Dog) -> Unit = { animal: Animal -> println(animal) } // Contravariant parameter
Real-World Applications
1. Using Variance in Collections
Kotlin’s List<T>
is declared as List<out T>
, meaning it is covariant.
val animals: List<Animal> = listOf(Dog(), Cat()) // Allowed because List is covariant
However, MutableList<T>
is invariant, meaning it cannot accept subtypes without explicit type casting.
val dogs: MutableList<Dog> = mutableListOf(Dog())
// val animals: MutableList<Animal> = dogs // Compilation error!
2. Variance in Function Interfaces
Consider a function interface for event handling:
interface EventListener<in T> {
fun onEvent(event: T)
}
This allows handling events of a subtype while still being assigned to a supertype listener:
val animalEventListener: EventListener<Animal> = object : EventListener<Animal> {
override fun onEvent(event: Animal) {
println("Handling event for ${event::class.simpleName}")
}
}
val dogEventListener: EventListener<Dog> = animalEventListener // Allowed due to contravariance
Best Practices
- Use
out
when a type is only produced (e.g., producers, collections for reading). - Use
in
when a type is only consumed (e.g., event handlers, function parameters). - Avoid using variance when both reading and writing are necessary (e.g.,
MutableList<T>
). - Use variance to make APIs more flexible and type-safe.
Conclusion
Variance in Kotlin (in
and out
) provides a powerful way to handle generics safely. Understanding when to use covariance (out
) and contravariance (in
) ensures that you can design APIs that are both flexible and type-safe.
By following these principles, you can write more reusable, robust, and maintainable Kotlin code.
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.