Generics & Variance
🧬 Generics & Variance
<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
<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:
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.
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:
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 invariant — Box<Dog> and Box<Animal> are entirely unrelated types, even if Dog is a subtype of 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.
Variance Cheat Sheet
| Modifier | Meaning | Type only appears... | Real example |
|---|---|---|---|
| (none) — invariant | No subtype relationship | In & out positions (read and write) | MutableList<T> |
| out — covariant | Producer<Dog> IS-A Producer<Animal> | Out (return values only) | List<out T> (Kotlin's List is read-only) |
| in — contravariant | Consumer<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:
<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:
💻 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.
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.
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.
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: Delegation — by lazy, observable properties, map delegation, and writing custom delegates.