Generics & Variance

Kotlin Intermediate — Generics & Variance
Kotlin Intermediate
Course 2 · Chapter 3 · Generics & Variance

🧬 Generics & Variance

Generics let a class or function work with any type while staying type-safe — familiar territory if you've used Java generics or TypeScript's <T> syntax. This chapter covers the parts that go beyond the basics: type erasure, the reified keyword that works around it, and variance (in/out) — the part of generics most likely to be genuinely new.

📐 Generic Classes & Functions — A Quick Refresher

class Box<T>(val content: T) fun <T> firstOrNull(list: List<T>): T? = if (list.isEmpty()) null else list[0] val intBox = Box(42) // Box<Int>, inferred val stringBox = Box("hi") // Box<String>, inferred

<T> is a placeholder type filled in at the call site — Box<Int> and Box<String> are different concrete types generated from the same generic definition, exactly as in Java or TypeScript. This part should already feel familiar; the rest of the chapter covers what's specific to Kotlin (and the JVM) about how generics actually behave.

🫥 Type Erasure

Like Java, Kotlin generics on the JVM are erased at runtime — the compiler enforces type safety while compiling, but the actual type parameter isn't available once the program is running:

fun <T> printType(value: T) { // println(value is T) // Compile error: cannot check for erased type T println(value!!::class.simpleName) // works — checks the RUNTIME class of the value itself }

At runtime, a List<String> and a List<Int> are genuinely indistinguishable — both are just a List. This isn't a Kotlin limitation specifically; it's how the JVM itself represents generics, and it's the same reason Java has this restriction too.

⚠ vs TypeScript's Erasure

TypeScript also erases types (they're compile-time-only, gone entirely in the emitted JavaScript), but for a different reason — TypeScript's types are a pure compile-time overlay on dynamically-typed JS. Kotlin's erasure is a JVM platform constraint: the types are real and enforced by the compiler, they just aren't retained in the compiled bytecode's generic parameters.

🔓 reified — Working Around Erasure

Marking an inline function's type parameter reified makes the actual type available at runtime, because the function's body is copied directly into every call site at compile time — there's no erasure to fight, since the real type is known at each individual call:

inline fun <reified T> isType(value: Any?): Boolean { return value is T // this compiles now — T is reified } println(isType<String>("hello")) // true println(isType<Int>("hello")) // false

reified only works on inline functions — the compiler needs to literally paste the function body into each call site to substitute the real type in, which is only possible for inlined code. This is exactly how library functions like filterIsInstance<T>() work under the hood.

Java's Workaround

Java has no equivalent to reified — the standard workaround is passing an explicit Class<T> object as an extra parameter (Gson's fromJson(json, MyClass.class) is a familiar example). Kotlin's reified avoids needing that extra parameter entirely.

Common Use: Type Checks & Casts

reified is mostly reached for when a generic function needs is T, as T, or T::class — anything that needs the actual runtime type, not just compile-time type safety.

↔️ Variance: in, out, and Invariant

By default, Kotlin generics are invariantBox<Dog> and Box<Animal> are entirely unrelated types, even if Dog is a subtype of Animal:

open class Animal class Dog : Animal() class Box<T>(var content: T) val dogBox: Box<Dog> = Box(Dog()) // val animalBox: Box<Animal> = dogBox // Compile error: Box<Dog> is not a Box<Animal>

This is deliberately strict, and for good reason: if it were allowed, code could put a Cat (also an Animal) into what's really a Box<Dog> through the Box<Animal> reference, silently breaking type safety. out and in exist to safely relax this in the specific cases where it's actually sound.

// out T — covariant: a producer/read-only type. Producer<Dog> IS-A Producer<Animal> interface Producer<out T> { fun produce(): T // T only ever comes OUT — safe to widen } val dogProducer: Producer<Dog> = object : Producer<Dog> { override fun produce() = Dog() } val animalProducer: Producer<Animal> = dogProducer // fine — out makes this safe // in T — contravariant: a consumer/write-only type. Consumer<Animal> IS-A Consumer<Dog> interface Consumer<in T> { fun consume(item: T) // T only ever goes IN — safe to narrow the other way } val animalConsumer: Consumer<Animal> = object : Consumer<Animal> { override fun consume(item: Animal) {} } val dogConsumer: Consumer<Dog> = animalConsumer // fine — in makes this safe: anything that can handle any Animal can handle a Dog

Variance Cheat Sheet

ModifierMeaningType only appears...Real example
(none) — invariantNo subtype relationshipIn & out positions (read and write)MutableList<T>
out — covariantProducer<Dog> IS-A Producer<Animal>Out (return values only)List<out T> (Kotlin's List is read-only)
in — contravariantConsumer<Animal> IS-A Consumer<Dog>In (parameters only)Comparator<in T>

This is exactly why Kotlin's read-only List<T> is declared out internally (it only ever produces Ts, never accepts one) while MutableList<T> is invariant (it both produces and accepts Ts via add()) — the compiler's variance rules directly mirror what's actually safe to do with the type.

🔒 Generic Constraints

A type parameter can be restricted to only accept types meeting some requirement, using an upper bound:

fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b println(max(3, 7)) // 7 — Int implements Comparable<Int> println(max("apple", "banana")) // banana — String implements Comparable<String> // max(Dog(), Dog()) // Compile error: Dog doesn't implement Comparable<Dog>

<T : Comparable<T>> means "T can be anything, as long as it implements Comparable<T>" — this is what makes a > b valid inside the function body, since the compiler now knows every possible T supports comparison. Multiple constraints use a separate where clause:

fun <T> describe(item: T) where T : Comparable<T>, T : CharSequence { // T must satisfy BOTH constraints here }

💻 Coding Challenges

Challenge 1: A Generic Constrained Function

Write a generic function fun <T : Comparable<T>> middle(a: T, b: T, c: T): T that returns the middle value of three (not the smallest, not the largest). Test it with three Ints and three Strings.

Goal: Practice writing a generic function with an upper-bound constraint.

→ Solution

Challenge 2: A reified Type Check Function

Write an inline function inline fun <reified T> countOfType(list: List<Any>): Int that returns how many elements in list are of type T. Test it on a mixed list of Ints, Strings, and Doubles, counting each type separately.

Goal: Practice reified type parameters for a genuine runtime type check.

→ Solution

Challenge 3: Covariance with out

Write an open class Animal(val name: String) and a subclass class Cat(name: String) : Animal(name). Write an interface interface Shelter<out T : Animal> with a function fun adopt(): T. Implement a CatShelter : Shelter<Cat>, then assign a CatShelter instance to a variable typed Shelter<Animal> and call adopt() through it.

Goal: See covariance (out) allow a more specific generic type to stand in for a more general one.

→ Solution

💡 out/in Read Like Their Names

A quick mnemonic: out T means T only flows out of the type (return values) — think "producer." in T means T only flows in (parameters) — think "consumer." If a type parameter is used both ways (as Course 1's mutable collections are), it has to stay invariant, since neither direction of subtyping is safe on its own.

🎯 What's Next

Next chapter: Delegationby lazy, observable properties, map delegation, and writing custom delegates.