Variance, Reified Generics, and Type Projections
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.
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
| Feature | Java ? | Kotlin * |
|---|---|---|
| Syntax | List<?> | List<*> |
| Reading | Returns Object | Returns upper bound of type parameter |
| Writing | Can’t write (except null) | Can’t write (type is Nothing) |
| With bounded types | List<? extends Animal> | Respects declared upper bound |
| With variance | No declaration-site variance | Interacts 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()
}
| Situation | Java Approach | Kotlin Approach |
|---|---|---|
| Read-only generic type | List<? extends T> everywhere | List<out T> in declaration |
| Write-only generic type | Comparable<? super T> everywhere | Comparable<in T> in declaration |
| Invariant class, read-only use | Box<? extends T> at use site | Box<out T> at use site |
| Runtime type check | Class<T> token parameter | reified T on inline function |
| Unknown type argument | List<?> | 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.