Kotlin Generics: Variance Without the Wildcards


Generics are the part of Java that 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 itself, instead of repeated at every use site.

This post follows delegation. It’s the most abstract stop in the series, so we’ll keep it grounded.

The basics carry over

A generic function or class takes a type parameter in angle brackets, and the compiler infers it 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

Nothing surprising. The interesting part is what happens when one generic type needs to stand in for another.

The variance problem

Here’s the puzzle every generics system has to answer: if Dog is an Animal, is a Box<Dog> a Box<Animal>? Intuitively yes, but allow it carelessly and you could put a Cat into a box meant for dogs. Java solves this with wildcards at the point of use — you write Box<? extends Animal> every time you accept one. Kotlin solves it at the point of declaration, once.

out: producers (covariance)

If a type only ever produces T — hands it out, never takes it in — then it’s safe to treat Box<Dog> as a Box<Animal>. You 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 version of ? extends, declared once on the interface. The compiler enforces the promise: a parameter marked out can’t appear in an input position, so you can’t accidentally break it. (This is exactly why List<out T> is covariant in Kotlin — a read-only list only produces elements.)

in: consumers (contravariance)

The mirror image: if a type only ever consumes T, then a Comparator<Animal> can stand in wherever a Comparator<Dog> is needed — it knows how to compare any animal, so dogs are fine. You 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 things you take out, in for things you put in.

Star projection

When you don’t know or don’t care about the type argument, Kotlin’s * is the equivalent of Java’s bare ?:

fun printSize(items: List<*>) = println(items.size)

You can inspect a List<*> and read it as List<Any?>, but you can’t add to it, since the real element type is unknown.

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 anymore. Kotlin’s escape hatch is reified, available on inline functions: because the function is inlined at each call site, the concrete type is known there, and you can use it:

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) style APIs you see in Kotlin libraries — no Class<T> argument to pass.

Final thoughts

Kotlin’s bet on generics 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. Pair that with reified to claw back the runtime type information erasure throws away, and the two features that make Java generics painful become the two that make Kotlin’s pleasant.

That wraps the language’s 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.