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.

Kotlin Generics: The Intuitive Guide to Covariance, Contravariance, and Invariance

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: CovarianceContravariance, 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.

Pro Tip: While we often say out makes 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.

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.

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.

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

Press enter or click to view image in full size
Quick Variance Cheat Sheet
Quick Variance Cheat Sheet

Common Mistakes to Avoid

  • ❌ Using MutableList<T> when List<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 out and then needing it as an input: You will hit a wall when you try to add a setter or add() 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 like List<out Any?> (you can read from it).
  • Comparator<*> behaves like Comparator<in Nothing> (you can't safely put anything into it because Nothing has 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 out or in in 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.

Comments

Popular posts from this blog

No More _state + state: Simplifying ViewModels with Kotlin 2.3

Why You Should Stop Passing ViewModels Around Your Compose UI Tree 🚫

Is Jetpack Compose Making Your APK Fatter? (And How to Fix It)