Skip to main content
kotlin in depth advanced patterns for java engineers

Variance, Reified Generics, and Type Projections

11 min read Chapter 3 of 21

Variance, Reified Generics, and Type Projections

Java generics are a compromise. They launched in Java 5 with type erasure for backward compatibility, use-site variance through wildcards, and a set of rules complex enough that most developers memorize PECS (Producer Extends, Consumer Super) as a survival mnemonic rather than understanding the underlying type theory. Kotlin took the opportunity to redesign generics from scratch — keeping compatibility with JVM erasure while fixing the usability problems at the language level.

Java’s Variance Problem, Stated Plainly

If Dog extends Animal, is List<Dog> a subtype of List<Animal>? In Java: no. Generics are invariant by default. This creates friction immediately:

// Java: This does NOT compile
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // Error: incompatible types

// Why? Because if this compiled, you could do:
animals.add(new Cat()); // Legal on List<Animal>
Dog dog = dogs.get(0);  // ClassCastException — it's actually a Cat

Java’s solution is wildcards at the use site:

// Java: Use-site variance with wildcards
List<? extends Animal> covariant = dogs;    // OK: read-only view
List<? super Dog> contravariant = animals;  // OK: write-only view

// And the mnemonic that nobody likes:
// PECS: Producer Extends, Consumer Super
void copy(List<? extends Animal> src, List<? super Animal> dst) {
    for (Animal a : src) dst.add(a);
}

The problem: every function that takes a generic parameter must redeclare the variance at the call site. If List<Dog> were inherently a producer of Animal values, you wouldn’t need wildcards at every usage point.

Declaration-Site Variance: Declare Once, Use Everywhere

Kotlin introduces declaration-site variance: you declare the variance on the type parameter at the class definition, not at every usage.

// Kotlin's List is declared as covariant
interface List<out E> {
    fun get(index: Int): E        // E appears in 'out' position (return type)
    fun isEmpty(): Boolean
    // fun add(element: E)        // Would be illegal — E in 'in' position
}

The out keyword means: “E is only used in output (return) positions.” This tells the compiler that List<Dog> is safely a subtype of List<Animal>:

val dogs: List<Dog> = listOf(Dog("Rex"), Dog("Buddy"))
val animals: List<Animal> = dogs  // Legal — List is covariant in E
println(animals[0].name)          // Works fine

No wildcards. No PECS. The variance is baked into the type definition.

Variance Diagram

out — Covariance: “This Type Produces T”

A type parameter declared out T can only appear in output positions: return types, val property types, and out-projected type arguments. The compiler enforces this at the declaration site:

interface Producer<out T> {
    fun produce(): T              // OK: T in return type
    val lastProduced: T           // OK: T as val property

    // fun consume(item: T)       // ERROR: T in 'in' position
    // var mutable: T             // ERROR: var has both getter (out) and setter (in)
}

// Covariance means: Producer<Dog> is a subtype of Producer<Animal>
fun feedAnimal(producer: Producer<Animal>) {
    val animal: Animal = producer.produce()
    animal.feed()
}

val dogProducer: Producer<Dog> = DogFactory()
feedAnimal(dogProducer)  // Legal — Producer<Dog> <: Producer<Animal>

in — Contravariance: “This Type Consumes T”

A type parameter declared in T can only appear in input positions: function parameters and var setter types (with restrictions).

interface Consumer<in T> {
    fun consume(item: T)          // OK: T in parameter position

    // fun produce(): T           // ERROR: T in 'out' position
}

// Contravariance means: Consumer<Animal> is a subtype of Consumer<Dog>
// (reversed from covariance — types flow in the opposite direction)
fun processDog(consumer: Consumer<Dog>) {
    consumer.consume(Dog("Rex"))
}

val animalConsumer: Consumer<Animal> = AnimalShelter()
processDog(animalConsumer)  // Legal — Consumer<Animal> <: Consumer<Dog>

The intuition: if something can consume any Animal, it can certainly consume a Dog. The subtyping relationship inverts — that’s why it’s called contravariance.

A Real-World Example: Comparable

Kotlin’s Comparable is declared with in:

interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}

This means Comparable<Animal> is a subtype of Comparable<Dog>. If you have a comparator that can compare any two animals, it can certainly compare two dogs.

fun sortDogs(dogs: MutableList<Dog>, comparator: Comparable<Dog>) {
    // ...
}

val animalComparator: Comparable<Animal> = Comparator { a, b ->
    a.name.compareTo(b.name)
}
sortDogs(mutableListOf(Dog("Z"), Dog("A")), animalComparator)  // Legal

In Java, you’d need Comparable<? super Dog> at the call site. In Kotlin, the subtyping relationship is built into the declaration of Comparable itself.

Bytecode Proof: Variance Compiles to Java Wildcards

Declaration-site variance is a compiler feature, not a JVM feature. At the bytecode level, Kotlin generates the same wildcards Java uses. Compile and decompile:

fun acceptAnimals(list: List<Animal>) {
    println(list.size)
}
kotlinc Variance.kt -include-runtime -d variance.jar
javap -s -p VarianceKt.class

The method signature in bytecode:

public static void acceptAnimals(java.util.List<? extends Animal>)

Kotlin’s List<Animal> compiles to Java’s List<? extends Animal> because List is declared out. The compiler inserts the wildcard for you — that’s the entire point. Declaration-site variance automates what Java makes you do manually at every call site.

Reified Type Parameters: Breaking Through Erasure

The JVM erases generic type arguments at runtime. In Java, List<String> and List<Integer> are the same class at runtime — both are java.util.List. This blocks legitimate operations:

// Java: Impossible at runtime
<T> boolean isInstance(Object obj) {
    return obj instanceof T;  // Error: cannot perform instanceof check against T
}

<T> T parse(String json) {
    return new Gson().fromJson(json, T.class);  // Error: cannot use T.class
}

Java’s workaround is passing Class<T> tokens:

// Java: Manual class token passing
<T> T parse(String json, Class<T> type) {
    return new Gson().fromJson(json, type);
}
String result = parse("{\"name\":\"Rex\"}", Dog.class); // Ugly but functional

Kotlin’s reified type parameters solve this for inline functions. When a function is inline, the compiler copies the function body to each call site. At that point, the concrete type argument is known — so the compiler substitutes it directly:

inline fun <reified T> isInstance(obj: Any): Boolean {
    return obj is T  // Legal — T is known at each call site
}

// Usage
println(isInstance<String>("hello"))  // true
println(isInstance<Int>("hello"))     // false

The compiler inlines the function body and replaces T with the concrete type:

// What the compiler actually generates at the call site:
println("hello" is String)  // Direct instanceof check
println("hello" is Int)     // Direct instanceof check

Practical Reified Examples

JSON deserialization without class tokens:

inline fun <reified T> Gson.fromJson(json: String): T {
    return fromJson(json, T::class.java)
}

// Usage — no Class<T> parameter needed
val dog: Dog = gson.fromJson("""{"name": "Rex"}""")
val config: AppConfig = gson.fromJson(configJson)

Type-safe service locator:

inline fun <reified T> ServiceRegistry.get(): T {
    return get(T::class.java)
}

// Usage
val userService: UserService = registry.get()
val dbConnection: DatabaseConnection = registry.get()

Filtering collections by type:

inline fun <reified T> List<*>.filterIsInstance(): List<T> {
    val result = mutableListOf<T>()
    for (element in this) {
        if (element is T) {  // Reified — runtime check works
            result.add(element)
        }
    }
    return result
}

val mixed: List<Any> = listOf(1, "two", 3, "four", 5.0)
val strings: List<String> = mixed.filterIsInstance<String>()
println(strings)  // [two, four]
val ints: List<Int> = mixed.filterIsInstance<Int>()
println(ints)     // [1, 3]

Reified Constraints

reified only works with inline functions. This isn’t arbitrary — it’s a direct consequence of how the JVM works. Non-inline functions exist as a single bytecode method that must handle all type arguments. Inline functions are copied to each call site, where the concrete type is known.

// This does NOT compile
fun <reified T> notInline(obj: Any): Boolean {
    return obj is T  // Error: reified requires inline
}

// Reified also cannot be used with:
// - Non-inline function calls (the type would be erased before reaching the callee)
// - Class type parameters (only function type parameters can be reified)
class Box<reified T>  // Error: type parameter of a class cannot be reified

Star Projections: Kotlin’s * vs Java’s ?

Java’s unbounded wildcard ? and Kotlin’s star projection * look similar but have subtle differences rooted in Kotlin’s declaration-site variance.

// Star projection: "I don't know (or care about) the type argument"
fun printSize(list: List<*>) {
    println(list.size)
    val element: Any? = list[0]  // Returns Any? — safest assumption
}

For a type declared interface Foo<out T : Upper>, Foo<*> means Foo<out Upper> — you get values as the upper bound.

For a type declared interface Bar<in T>, Bar<*> means Bar<in Nothing> — you can’t pass anything in safely.

For an invariant type class Baz<T>, Baz<*> is Baz<out Any?> for reads and Baz<in Nothing> for writes.

class MutableBox<T>(var value: T)

fun readBox(box: MutableBox<*>) {
    val value: Any? = box.value   // OK: read as Any?
    // box.value = "new"          // ERROR: can't write — type unknown
}

Star vs Wildcard Comparison

FeatureJava ?Kotlin *
SyntaxList<?>List<*>
ReadingReturns ObjectReturns upper bound of type parameter
WritingCan’t write (except null)Can’t write (type is Nothing)
With bounded typesList<? extends Animal>Respects declared upper bound
With varianceNo declaration-site varianceInteracts with in/out declarations

Use-Site Type Projections: When Declaration-Site Isn’t Enough

Sometimes a class uses a type parameter in both in and out positions, so you can’t declare it as covariant or contravariant. Array is the canonical example:

class Array<T>(val size: Int) {
    operator fun get(index: Int): T { ... }       // T in out position
    operator fun set(index: Int, value: T) { ... } // T in in position
}

Array<T> is invariant — Array<Dog> is not a subtype of Array<Animal>. But what if you want to write a copy function?

// Won't compile without projections
fun copy(from: Array<Animal>, to: Array<Animal>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

val dogs: Array<Dog> = arrayOf(Dog("Rex"), Dog("Buddy"))
val animals: Array<Animal> = arrayOf()
// copy(dogs, animals)  // Error: Array<Dog> is not Array<Animal>

Use-site projection solves this — the same concept as Java wildcards, but with clearer syntax:

fun copy(from: Array<out Animal>, to: Array<in Animal>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

val dogs: Array<Dog> = arrayOf(Dog("Rex"), Dog("Buddy"))
val animals: Array<Animal> = arrayOfNulls<Animal>(2) as Array<Animal>
copy(dogs, animals)  // Legal: Array<Dog> matches Array<out Animal>

Array<out Animal> is Kotlin’s equivalent of Java’s Array<? extends Animal>. Array<in Animal> is Array<? super Animal>. The difference is one of defaults: in Kotlin, you reach for use-site projections only when the class is invariant and declaration-site variance doesn’t cover your case. In Java, you reach for wildcards every time.

Practical Variance Guidelines

Here’s when to use each variance mechanism:

// 1. Declaration-site OUT: your class only produces T
interface EventStream<out E> {
    fun next(): E
    fun hasMore(): Boolean
}

// 2. Declaration-site IN: your class only consumes T
interface EventHandler<in E> {
    fun handle(event: E)
}

// 3. Invariant: your class both reads and writes T
class MutableContainer<T>(var value: T)

// 4. Use-site projection: invariant class, but you only need one direction
fun readFrom(container: MutableContainer<out Animal>): Animal {
    return container.value  // OK: projected as out
    // container.value = Cat()  // Error: can't write to out-projected type
}

// 5. Star projection: you don't need the type parameter at all
fun countElements(list: List<*>): Int = list.size

// 6. Reified: you need the type at runtime
inline fun <reified T : Any> createInstance(): T {
    return T::class.java.getDeclaredConstructor().newInstance()
}
SituationJava ApproachKotlin Approach
Read-only generic typeList<? extends T> everywhereList<out T> in declaration
Write-only generic typeComparable<? super T> everywhereComparable<in T> in declaration
Invariant class, read-only useBox<? extends T> at use siteBox<out T> at use site
Runtime type checkClass<T> token parameterreified T on inline function
Unknown type argumentList<?>List<*> (respects bounds)

The shift is architectural. In Java, every consumer of a generic type must reason about variance. In Kotlin, the author of the generic type encodes the variance once, and every consumer benefits automatically. Use-site projections exist as an escape hatch for the cases declaration-site can’t handle — and in practice, those cases are rare.