skip to Main Content

Stop Memorizing ‘in’ and ‘out’: Use This Simple Mental Model for Kotlin Variance

March 1, 20265 minute read

  

Stop Memorizing ‘in’ and ‘out’: Use This Simple Mental Model for Kotlin Variance

Image build using ChatGPT

You don’t need to memorize the theory to use variance like a pro — you just need a simple mental model.
This guide gives you that model.

If you’ve ever wondered why:

  • List<Int> works where List<Number> is expected
  • MutableList<Int> does not
  • Comparator<Number> can act as a Comparator<Int>
  • or what in, out, and invariance really mean

…this article will make Kotlin variance finally click.

1. Why Variance Matters

Variance determines how one generic type relates to another when their type parameters are in a subtype relationship.

In other words:

If Int is a subtype of Number, can
Box<Int> be treated as Box<Number>?

Sometimes yes, sometimes no — and variance is the reason.

Understanding this makes you better at:

  • designing clean APIs
  • reading Kotlin standard library code
  • passing interviews
  • avoiding subtle runtime errors

Let’s start with the problem variance solves.

2. The Problem Variance Solves

Consider a simple mutable wrapper:

class Box<T>(var value: T)

Intuitively, you might think:

“Int is a Number, so Box<Int> should be a Box<Number>, right?”

But if Kotlin allowed this, things get unsafe quickly.

❌ Imagine this was legal:

val intBox: Box<Int> = Box(10)
val numBox: Box<Number> = intBox   // hypothetically allowed

Now someone working with numBox does this:

numBox.value = 3.14     // inserting Double into Box<Int>

And then:

val x: Int = intBox.value    // BOOM

Kotlin prevents this at compile time by making some generic types invariant.

This brings us to variance.

3. A Simple Mental Model: Flow of Types

Before we go deeper, here’s the easiest way to understand variance:

Think of the type hierarchy as a vertical flow:

Generated using Figma and Gemini

Now:

  • Covariance (out) → the flow goes upward (subtype → supertype)
  • Contravariance (in) → the flow goes downward (supertype → subtype)
  • Invariance → no flow allowed

Keep this mental image with you — it makes everything below intuitive.

4. Covariance (out) — Producers

Covariance means:

When A is a subtype of B,
Producer<A> can be used where Producer<B> is expected.

You mark it with:

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

A covariant type only produces values of type T.
It never accepts them — so subtype substitution is always safe.

Example

val intProducer: Producer<Int> = ...
val numProducer: Producer<Number> = intProducer // ✔ allowed

Real-world Kotlin example

val ints: List<Int> = listOf(1, 2, 3)
val nums: List<Number> = ints // ✔ List is covariant

List is read-only → a perfect example of a producer.

5. Contravariance (in) — Consumers

Contravariance means:

When A is a subtype of B,
Consumer<B> can be used where Consumer<A> is expected.

You mark it with:

interface Consumer<in T> {
fun accept(value: T)
}

A contravariant type only consumes values of type T.

Example

val numConsumer: Consumer<Number> = ...
val intConsumer: Consumer<Int> = numConsumer // ✔ allowed

Real-world Kotlin example

val numberComparator: Comparator<Number> = ...
val intComparator: Comparator<Int> = numberComparator // ✔ allowed

Comparators consume values perfect match.

6. Invariance — When a Type Is Both Producer and Consumer

If a class both reads and writes the type parameter, Kotlin forces invariance.

Example:

class Box<T>(var value: T)

Because Box allows both:

  • getting T
  • setting T

…it’s unsafe to treat Box<Int> as Box<Number> or vice versa.

val intBox: Box<Int> = Box(1)
// val numBox: Box<Number> = intBox // ❌ not allowed

This prevents type pollution and runtime crashes.

✅ Use-Site Variance

Sometimes you cannot change the original generic class (like Box<T>),
but you still want to restrict how it’s used at a specific call site.

Kotlin allows this using use-site variance, also called type projections.

// Use-site variance with safer naming

fun readOnlyView(source: Box<out Number>, target: MutableList<Number>) {
// This box behaves like a read-only producer of Number

target.add(source.get()) // ✅ Allowed: reading as Number is safe

// source.set(50) // ❌ Not allowed: write is blocked by projection
}

fun writeOnlyView(destination: Box<in Number>, input: List<Int>) {
// This box behaves like a write-only consumer of Number

destination.set(input.first()) // ✅ Allowed: Int is accepted as Number

val result: Any? = destination.get() // ✅ Allowed, but only as Any?

// val num: Number = destination.get() // ❌ Not allowed: return type is too generic
}

7. How the Kotlin Standard Library Uses Variance

Generated using Figma AI

Notice a pattern:

Only-producers → out
Only-consumers →
in
Both → invariant

This rule guides API design in the Kotlin ecosystem.

8. Quick Summary Table

Generated using Figma AI

Cheat Sheet

Generated using Gemini Nano Banana

Closing Thoughts

Variance isn’t something you need to memorize.
Once you understand how type flow works — up, down, or not at all — the rules become obvious.

If this mental model helped you, follow me for more Kotlin deep-dives, Android insights, and practical developer guides.

🔗 Let’s connect on LinkedIn: Shashank Pednekar | LinkedIn

🐦 Follow me on Twitter (X): https://x.com/ShashankP27


Stop Memorizing ‘in’ and ‘out’: Use This Simple Mental Model for Kotlin Variance was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.

 

Web Developer, Web Design, Web Builder, Project Manager, Business Analyst, .Net Developer

No Comments

This Post Has 0 Comments

Leave a Reply

Back To Top