Generics & Variance Explained

KOTLIN › Types & Classes

Before the in/out keywords, the idea they express: **variance**. It answers one question: you already know Dog is a subtype of Animal, but does that make Box<Dog> a subtype of Box<Animal>? There are three possible answers, and their names, **invariant**, **covariant**, and **contravariant**, come from type theory. They are not Kotlin keywords themselves.

Kotlin's default answer is **invariant**: no relationship at all. class Box<T>(val value: T) gives you Box<Dog> and Box<Animal> as two completely unrelated types, even though Dog *is* an Animal.

open class Animal
class Dog : Animal()

class Box<T>(val value: T)

val dogBox: Box<Dog> = Box(Dog())
// val animalBox: Box<Animal> = dogBox   // error: Box<Dog> is not a Box<Animal>

Why so strict? Because Box never says whether T is only ever *produced* (read out) or only ever *consumed* (written in), so the compiler assumes both and plays it safe. Two modifiers change that: **out**, which makes a type covariant, and **in**, which makes it contravariant. They tell the compiler which role T really plays, and that unlocks the subtyping. This lesson builds up to both, then to what happens when a type's generic information is stripped away entirely at runtime.

Before variance, the basics: a generic class or function declares its type parameters in angle brackets right after its name.

class Box<T>(val value: T)
fun <T> firstOf(list: List<T>): T = list[0]

By default a type parameter's upper bound is Any?, so it accepts anything. Narrow it with an **upper bound**: <T : Comparable<T>> restricts T to types implementing Comparable<T>, which unlocks calls like a.compareTo(b) inside the body.

fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b

When T must satisfy more than one bound at once, a single : isn't enough, use a **where clause** listing each constraint separately:

fun <T> longest(a: T, b: T): T
        where T : CharSequence, T : Comparable<T> =
    if (a.length >= b.length) a else b

Both constraints must hold for whatever T ends up being.

**out T makes a type covariant: a producer.** Declare it on a generic that only ever *returns* T, never takes it as a parameter, and Producer<Dog> becomes a genuine subtype of Producer<Animal>:

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

val dogProducer: Producer<Dog> = object : Producer<Dog> {
    override fun produce() = Dog()
}
val animalProducer: Producer<Animal> = dogProducer   // OK: covariant

This is sound because you can only ever *read* a T out of it. If dogProducer only ever hands you Dogs, treating it as 'something that hands me Animals' is always safe, every Dog is an Animal. The trade-off: T may only appear in out positions in the public API; using it as a parameter type is a compile error.

The same idea works with a bound attached. A class that only ever reads its stored value, like class Box<out T : Number>(val value: T), combines a bound with covariance: T is restricted to Number subtypes, and because Box never writes T back in, it's safe to make it covariant too. That means Box<Int> and Box<Double> are both subtypes of Box<Number>, so a single List<Box<Number>> can hold boxes of different concrete numeric types side by side, something an invariant Box<T> could never let you mix into one list. Summing them is then just reading .value.toDouble() off each one: boxes.sumOf { it.value.toDouble() }.

**in T makes a type contravariant: a consumer.** Declare it on a generic that only ever *accepts* T as a parameter, never returns it, and the subtyping runs the other way: Consumer<Animal> becomes a subtype of Consumer<Dog>. You'll also see this role called a **handler**: same shape, same reasoning, just a different name for something that consumes a value instead of producing one.

interface AnimalHandler<in T> {
    fun handle(item: T)
}

val handlesAnyAnimal: AnimalHandler<Animal> = object : AnimalHandler<Animal> {
    override fun handle(item: Animal) { }
}
val handlesDogs: AnimalHandler<Dog> = handlesAnyAnimal   // OK: contravariant

That's sound too: something that can handle *any* Animal can certainly handle the more specific Dogs you hand it. Comparable<in T> is the canonical real-world example: a Comparable<Number> can compare Doubles just fine, so it can stand in wherever a Comparable<Double> is expected.

**Why is Array<T> invariant while List<T> is covariant?** It comes down to what each interface actually lets you do with T. Array exposes both get(): T (a producer position) and set(value: T) (a consumer position), T sits in both roles, so neither out nor in is sound, Array has to stay invariant.

List (the read-only Kotlin interface, not MutableList) only exposes get(): T-style members, no way to insert or replace an element, so T only ever appears in producer positions. That's exactly what out requires, and the standard library declares it interface List<out E>, making List<String> a genuine subtype of List<Any>.

fun printAll(items: List<Any>) = items.forEach(::println)
printAll(listOf("a", "b"))   // OK: List<String> is a List<Any>

MutableList<T>, which adds add or set, has to stay invariant for the same reason Array does.

**Use-site variance (type projections) apply variance at a single call, not on the whole class.** Array<T> is invariant by declaration, but you can still project it locally with out or in at a specific function parameter:

fun printAll(arr: Array<out Any>) {
    val item = arr[0]     // OK: get() is a producer op, allowed under out
    // arr[0] = "x"      // ERROR: set() forbidden, would violate the out projection
}

printAll(arrayOf(1, 2, 3))   // Array<Int> accepted safely as Array<out Any>

Inside printAll, arr is treated as *read-only* for this one parameter, which is enough to safely accept an Array<Int> where an Array<Any> is expected, flexibility Kotlin's declaration-site out or in can't offer for an inherently invariant type like Array. This is Kotlin's answer to Java's ? extends T or ? super T wildcards, but opt-in per use instead of mandatory everywhere.

The same trick works even for a class that stayed plain invariant. Had Box<T> not been declared out, List<Box<out Number>> would still get you the same read-only mixing of Box<Int> and Box<Double> at that one call site, without requiring Box itself to commit to covariance everywhere.

Kotlin's out/in are **declaration-site** variance: you say it once on the class and it applies everywhere. Java only has **use-site** variance, wildcards written at each call, ? extends T and ? super T. When Kotlin code calls into Java, it maps those wildcards onto the same projections you already know.

A Java method declared void addAll(Collection<? extends T> items) is seen from Kotlin as Collection<out T>, both mean 'a producer of T (or some subtype), safe to read from.' A ? super T lower bound maps the other way, onto Collection<in T>, 'a consumer that can accept T.'

// Java: void addAll(Collection<? extends T> items)
fun <T> addAll(items: Collection<out T>) { }

Same idea, two syntaxes, Kotlin's is just declared once instead of repeated at every use site.

Declaration-site variance is only checked against a class's **public API**, not its private internals. That means a covariant class Source<out T> can still put T in an in-position, a consumer role, as long as it's private:

class Source<out T> {
    private val cache = mutableListOf<@UnsafeVariance T>()

    fun contains(item: @UnsafeVariance T): Boolean = cache.contains(item)
}

The compiler still flags T in an in-position on a **public** member by default, that's the whole point of declaring out. The @UnsafeVariance annotation is the escape hatch: it tells the compiler 'I know this looks unsound from the outside, trust me,' and is typically reached for when a public function needs to accept a T to compare against or look up, without actually storing a caller-supplied value long-term.

**Star projection Foo<*> means 'some specific type, but I don't know or care which.'** What it resolves to depends on how the parameter was declared:

- Foo<out T : Upper> behaves like Foo<out Upper> (safe to read as Upper) - Foo<in T> behaves like Foo<in Nothing> (nothing can be safely written) - Invariant Foo<T : Upper> reads as Upper, writes are disallowed

interface Consumer<in T> { fun consume(t: T) }

val star: Consumer<*> = someConsumer
// star.consume("x")   // error: parameter type is Nothing, effectively uncallable

Nothing is the type with no instances, so Foo<in Nothing> is a way of saying 'there is no value you can safely pass here.' Star projection shows up constantly in code that only needs to check *shape*, not manipulate contents, like value is List<*>.

**Generic type arguments are erased at runtime.** List<String> and List<Int> compile down to the same raw List class on the JVM, the compiler enforces type safety at compile time but throws that information away afterward. That's why you can't check a specific type argument at runtime:

val value: Any = listOf(1, 2, 3)

if (value is List<*>) { }          // OK, star projection sidesteps the erased argument
// if (value is List<Int>) { }     // error: cannot check for erased type

The same erasure also produces JVM signature clashes you wouldn't expect from the Kotlin source:

fun f(p: List<String>) { }
// fun f(p: List<Int>) { }   // error: platform declaration clash, same erased signature

Both f overloads erase to the identical JVM method f(List), so the compiler rejects the second one outright, even though the Kotlin types are clearly distinct.

**reified type parameters recover what erasure normally throws away, but only inside inline functions.** Marking a type parameter reified lets you use it at runtime: is T, as T, T::class, all otherwise impossible for a generic T.

inline fun <reified T> Any?.isInstance(): Boolean = this is T

"hello".isInstance<String>()   // true

The inline requirement isn't incidental, reified literally can't exist without it. Inlining copies the function's body into every call site, and at each call site the compiler knows the concrete type argument, so it substitutes T directly into the copied code. A normal, non-inline, generic function has one shared compiled body with no call-site-specific substitution to lean on, so T stays erased there no matter what.

That's exactly how you'd write a type-filtering helper by hand:

inline fun <reified T> List<*>.filterByType(): List<T> =
    filter { it is T }.map { it as T }

The reified T is what lets it is T and it as T compile at all. The standard library ships the same idea as filterIsInstance<T>().

Put the pieces together and the whole topic collapses into two questions an interviewer is really asking. First: does subtyping between two generic types even relate to the subtyping of their type arguments? Declaration-site out and in answer that once, on the class itself, a Producer<Dog> stands in for a Producer<Animal>, a handler of Animal stands in for a handler of Dog. Use-site projections, Array<out Any> and friends, answer the same question locally, at a single parameter, for types like Array that can't commit to variance everywhere. Star projection is the shrug: 'some type, I'm not tracking which', and it degrades to out Upper or in Nothing depending on which side of that question the parameter sits on.

Second: what actually survives to runtime? Nothing generic does, by default. List<String> and List<Int> are the same class once compiled, which is why is List<Int> is illegal and two overloads that only differ in a type argument clash. reified, available only inside inline functions because inlining is what gives the compiler a real call site to substitute into, is the one escape hatch: it's how filterByType or is T can work at all.

In an interview, the fast answer is: Kotlin variance is declared on the class with out/in, or projected locally at a use site when the class can't commit either way; generics are erased at runtime except where reified recovers them through inlining. That sentence, unpacked, is this whole lesson.

Back to Generics & Variance