Kotlin Generics: Variance Without the Wildcards
Generic functions and classes, invariance by default, declaration-site variance with out and in versus use-site projections, type bounds and where clauses, star projections, and reified type parameters on inline functions.
Generics are the part of Java everyone uses and few fully understand. The basics are fine (List<String> is clear enough), but the moment wildcards appear (List<? extends Number>, List<? super Integer>) most of us start guessing. Kotlin keeps the useful core and rethinks the confusing part: variance is declared once, on the type, instead of repeated at every use. This is the most abstract chapter in the series, so we’ll keep it grounded and run every claim against Kotlin 2.4.10.
This chapter follows delegation.
The basics carry over
A generic function or class takes a type parameter in angle brackets, inferred at the call site:
fun <T> firstOrNull(list: List<T>): T? = if (list.isEmpty()) null else list[0]
class Box<T>(val value: T)
val b = Box("hello") // T inferred as String
One null-safety detail from that chapter bears repeating: a bare <T> has upper bound Any?, so it permits null. Write <T : Any> when the parameter must be non-null. The interesting part, though, is what happens when one generic type stands in for another.
Invariance is the default
By default Kotlin generics are invariant: Box<Dog> is not a Box<Animal>, and MutableList<Dog> is not a MutableList<Animal>, even though Dog is an Animal. That feels strict, but it’s the safe default — if a MutableList<Dog> were usable as a MutableList<Animal>, you could add a Cat to it and corrupt the list. Every generics system has to answer this, and Kotlin’s answer starts from “no substitution unless you prove it’s safe.”
out: producers (covariance)
If a type only ever produces T — hands it out, never takes it in — then treating Box<Dog> as Box<Animal> is safe. Mark the type parameter out:
interface Source<out T> { fun next(): T } // T only comes out
val dogs: Source<Dog> = /* ... */
val animals: Source<Animal> = dogs // allowed, because of `out`
out is Kotlin’s ? extends, declared once on the interface, and the compiler enforces the promise: a parameter marked out cannot appear in an input position, so you can’t accidentally break the guarantee. This is exactly why List<out T> is covariant — a read-only list only produces elements — while MutableList<T> is invariant, because it also consumes them.
in: consumers (contravariance)
The mirror image: if a type only ever consumes T, a Sink<Animal> can stand wherever a Sink<Dog> is needed — it accepts any animal, so dogs are fine. Mark it in:
interface Sink<in T> { fun accept(value: T) } // T only goes in
val animalSink: Sink<Animal> = /* ... */
val dogSink: Sink<Dog> = animalSink // allowed, because of `in`
in is Kotlin’s ? super. The mnemonic the standard library uses: out for what you take out, in for what you put in.
Declaration-site vs use-site
The two paragraphs above are declaration-site variance: you annotate the type once at its definition and every use benefits. Sometimes, though, you can’t change the type’s definition (it’s someone else’s, or it’s genuinely invariant like Array<T>) but you want covariance at one spot. Kotlin allows a use-site projection there, which is the same idea applied to a single usage and reads exactly like Java’s wildcard:
fun copy(from: Array<out Any>, to: Array<Any>) { /* from is read-only here */ }
Array<out Any> says “this parameter only produces, so I’ll accept an Array<String>.” Kotlin’s preference is declaration-site (annotate once, benefit everywhere); use-site projections are the escape hatch for the cases declaration-site can’t reach. If Java’s wildcards were the only tool you had, Kotlin projections are that same tool, demoted to the fallback it should always have been.
Type bounds
Constrain a type parameter with a bound, so an operation applies only where it makes sense:
fun <T : Comparable<T>> maxOf(a: T, b: T): T = if (a > b) a else b
T : Comparable<T> means “any T that can compare to itself,” which is what lets the body use >. For multiple bounds — a T that must satisfy two constraints — the single-colon form isn’t enough; use a where clause:
fun <T> render(item: T): String where T : CharSequence, T : Comparable<T> =
"$item (length ${item.length})"
Star projection
When you don’t know or don’t care about the type argument, * is the equivalent of Java’s bare ?:
fun printSize(items: List<*>) = println(items.size)
You can read a List<*> as List<Any?> — every element is some type, so it’s safely Any? — but you can’t add to it, because the real element type is unknown and the compiler won’t let you smuggle in the wrong thing.
reified: keeping the type at runtime
Generics are erased on the JVM: at runtime List<String> and List<Int> are the same List, so T::class or is T normally won’t compile — the type isn’t there. The escape hatch is reified, available on inline functions. Because the function is inlined at each call site, the concrete type is known there:
inline fun <reified T> Any.asOrNull(): T? = this as? T
val name = value.asOrNull<String>() // real type check, no erasure
This is what powers the clean fromJson<User>(text) and filterIsInstance<String>() APIs across Kotlin libraries — no Class<T> token to thread through every call, because inlining substitutes the real type where erasure would otherwise have thrown it away.
Final thoughts
Kotlin’s bet is that variance is a property of the type, not of each use, so you declare out or in once and every call site benefits — no wildcard soup — with use-site projections kept as the fallback for the types you can’t annotate. Invariance is the safe default, bounds and where constrain what a parameter can be, and reified claws back the runtime type information erasure throws away. The two features that make Java generics painful — wildcards and erasure — become the two that make Kotlin’s pleasant.
That wraps the type machinery. Next we deal with what happens when things go wrong: exceptions, and why Kotlin threw out checked exceptions entirely.
Practice: reinforce this with the companion workbook — short, click-to-reveal problems.
Comments