Kotlin Generics: The Intuitive Guide to Covariance, Contravariance, and Invariance
Stop fighting the compiler—Master the 'in' and 'out' of type safety with real-world analogies and Postel’s Law.
If you’ve ever tried to pass a List<String> into a function expecting a List<Any> and been met with a cold, hard compiler error, you’ve hit the wall of Variance.
Variance determines how generic types relate to each other when their type parameters have a parent-child relationship. In Kotlin, this revolves around three pillars: Covariance, Contravariance, and Invariance.
Let’s demystify these concepts with real-world analogies and idiomatic Kotlin code.
1. What is Variance, Anyway?
In simple terms: if a Dog is an Animal, should a Box<Dog> be considered a Box<Animal>?
By default, the answer in Kotlin is No. This strictness exists to prevent you from accidentally putting a Cat into a Box<Dog> via a reference that thinks it’s just a Box<Animal>. Variance is the mechanism that allows us to tell the compiler when it is safe to relax these restrictions.
2. Covariance: The “Producer” (out)
Definition: If A is a subtype of B, then Class<A> is a subtype of Class<B>.
Covariance preserves the subtype relationship. In Kotlin, we use the out keyword. It signals to the compiler that the type parameter will only appear in output positions (like return types).
Generic Covariance in Action
When we use out T, we guarantee that T will only ever come out of the class.
// 'out' makes S covariant
interface Producer<out S> {
fun produce(): S
// fun consume(item: S)
// ❌ ERROR: Type parameter S is declared as 'out' but occurs in 'in' position
}
fun main() {
val dogList: List<Dog> = listOf(Dog())
val animalList: List<Animal> = dogList // ✅ Works! List is defined as List<out E>
}Pro Tip: While we often say
outmakes a container "read-only," it is more accurate to say it restricts the API surface. A class can still have internal private mutability as long as it doesn't expose S as an input in public functions.
3. Contravariance: The “Consumer” (in)
Definition: If A is a subtype of B, then Class<B> is a subtype of Class<A>.
This “flips” the relationship. We use the in keyword because the class only consumes the type (it appears as an argument in functions).
The Intuition: The Generalist
Imagine a Printer<Document>. Because it can handle any general document, it can safely handle a PdfDocument. The relationship is reversed: the general handler is a safe substitute for the specific one.
val numberComparator = Comparator<Number> { n1, n2 ->
n1.toDouble().compareTo(n2.toDouble())
}
fun main() {
val ints = listOf(1, 5, 2)
// Safe because if you can compare any Number, you can compare Ints.
// Comparator is defined as Comparator<in T>
println(ints.sortedWith(numberComparator))
}4. Invariance: The Rigid Guard
If a generic parameter has neither in nor out, it is Invariant. This means Box<A> and Box<B> have no relationship. Invariance is required when a type parameter appears in both input and output positions.
class Box<T>(var item: T) // T is used in 'in' (setter) and 'out' (getter)
fun main() {
val dogBox: Box<Dog> = Box(Dog())
// val animalBox: Box<Animal> = dogBox // ❌ ERROR
// If this worked, we could put a Cat into a Dog Box!
}5. Advanced: Use-Site Variance
Sometimes, a class is invariant (like Array), but you want a specific function to be flexible. This is called Use-site variance.
fun copy(from: Array<out Animal>, to: Array<in Animal>) {
for (i in from.indices) {
to[i] = from[i]
}
}In this function, we treat the from array as a producer (read-only) and the to array as a consumer (write-only). This allows us to copy an Array<Dog> into an Array<Animal> safely.
🚀 Quick Variance Cheat Sheet

Common Mistakes to Avoid
- ❌ Using
MutableList<T>whenList<T>is enough: This prevents you from benefiting from covariance. - ❌ Slapping
*(star projection) to silence the compiler: This is a "code smell" that usually means your variance isn't defined correctly. - ❌ Marking a type as
outand then needing it as an input: You will hit a wall when you try to add asetteroradd()method.
💬 Frequently Asked Questions (FAQs)
Can a class have both in and out parameters?
Absolutely, just not on the same parameter. The standard library’s Function1<in P, out R> is the perfect example: it consumes a parameter P and produces a result R.
Why is MutableList invariant?
Because it allows both add(element: T) and get(): T. To prevent type-safety crashes (like putting a Grape into a List<Apple>), Kotlin requires the type to match exactly.
What exactly is a Star Projection (*)? A: It is a "don't care" shortcut.
List<*>behaves likeList<out Any?>(you can read from it).Comparator<*>behaves likeComparator<in Nothing>(you can't safely put anything into it becauseNothinghas no instances).
Is there a performance cost at runtime?
No. Variance is a compile-time safety check. The generated bytecode doesn’t contain in or out modifiers; it's all about keeping your code safe during development.
💬 Reader Reflection
- Have you noticed how
Function1<in P, out R>perfectly implements Postel's Law? ("Be liberal in what you accept, conservative in what you send"). - Try refactoring a function today to use
outorinin the parameters—does it make the function easier to use?
📘 Master Your Next Technical Interview
Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.
- E-book (Best Value! 🚀): $1.99 on Google Play
- Kindle Edition: $3.49 on Amazon
- Also available in Paperback & Hardcover.

Comments
Post a Comment