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

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:

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

Notice a pattern:
Only-producers → out
Only-consumers → in
Both → invariant
This rule guides API design in the Kotlin ecosystem.
8. Quick Summary Table

Cheat Sheet

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.




This Post Has 0 Comments