09 · Generics¶
Generics let a class or function work with any type while still giving you
full compile-time type checking — no casts, no Any grab-bags. Kotlin's
generics build on the same erasure model as Java's, but add two things Java
doesn't have in this form: declaration-site variance (in/out) and
reified type parameters that survive into runtime for inline functions.
Generic classes and functions¶
A type parameter in angle brackets (<T>) stands in for a real type,
supplied when the class is instantiated or the function is called — usually
inferred from the argument, so you rarely write it out explicitly.
class Box<T>(val item: T) {
fun get(): T = item
}
fun <T> firstOrNull(list: List<T>): T? = if (list.isEmpty()) null else list[0]
fun main() {
val intBox = Box(42)
val stringBox = Box("hello")
println(intBox.get())
println(stringBox.get())
println(firstOrNull(listOf(1, 2, 3)))
println(firstOrNull(emptyList<String>()))
}
Constraints: limiting what T can be¶
An upper bound (T : SomeType) restricts a type parameter to types that
extend/implement SomeType, which lets you call SomeType's members on a
value of type T. Comparable<T> is the most common bound — it's how
> and < become available for a generic value.
fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b
fun main() {
println(max(3, 7)) // 7
println(max("apple", "banana")) // banana
}
Without the Comparable<T> bound, a > b wouldn't compile — the compiler
has no idea whether an arbitrary T supports ordering at all.
Declaration-site variance: out (covariance)¶
By default, Box<Dog> and Box<Animal> are unrelated types even if Dog
extends Animal — generics are invariant unless you say otherwise. Marking
a type parameter out says "this class only ever produces T values
(returns them, never accepts them as a parameter)," which lets the compiler
treat Container<Dog> as a subtype of Container<Animal>.
open class Animal(val name: String)
class Dog(name: String) : Animal(name)
class Container<out T>(val value: T)
fun printAnimal(container: Container<Animal>) {
println("Contains: ${container.value.name}")
}
fun main() {
val dogContainer: Container<Dog> = Container(Dog("Rex"))
printAnimal(dogContainer) // OK -- Container<Dog> is a subtype of Container<Animal>
}
This is exactly why List<out T> lets you pass a List<Dog> anywhere a
List<Animal> is expected, but MutableList<T> (which also accepts T
via add) can't safely be out — accepting an Animal into what's really a
MutableList<Dog> would break type safety, so the compiler forbids marking
a type parameter out if it's ever used as a function parameter type.
Declaration-site variance: in (contravariance)¶
in is the mirror image: it says "this class only ever consumes T
values (accepts them as parameters, never returns them)." That makes
Feeder<Animal> usable anywhere a Feeder<Dog> is needed — a feeder that
knows how to handle any animal can certainly handle a dog.
open class Animal(val name: String)
class Dog(name: String) : Animal(name)
interface Feeder<in T> {
fun feed(animal: T): String
}
fun main() {
val animalFeeder: Feeder<Animal> = object : Feeder<Animal> {
override fun feed(animal: Animal) = "Feeding ${animal.name} some generic chow"
}
val dogFeeder: Feeder<Dog> = animalFeeder // OK -- Feeder<in T> is contravariant
println(dogFeeder.feed(Dog("Rex")))
}
Comparator<in T> in the standard library works the same way: a
Comparator<Any> can compare two Strings just fine, so it's valid
anywhere a Comparator<String> is required.
| Variance | Keyword | Type can be used as | Typical use |
|---|---|---|---|
| Covariant | out T |
A return type only | Producers — List<out T>, Container<out T> |
| Contravariant | in T |
A parameter type only | Consumers — Comparator<in T>, Feeder<in T> |
| Invariant | T (neither) |
Both | Anything that both stores and returns T — MutableList<T> |
Star projection: <*> when you don't care¶
Sometimes you need to accept a generic type without caring what its type
argument actually is. List<*> means "a List of something" — you can
read elements out as Any?, but you can't add anything to it (the compiler
has no idea what type would be safe to insert).
fun printAllItems(list: List<*>) {
for (item in list) {
println(item)
}
}
fun main() {
printAllItems(listOf(1, 2, 3))
printAllItems(listOf("a", "b", "c"))
}
Reified type parameters: T that survives at runtime¶
Normally, generic type information is erased at runtime — a compiled
List<String> and List<Int> look identical to the JVM, so you can't write
if (value is T) in an ordinary generic function (there's nothing left at
runtime to check against). Marking an inline function's type parameter
reified fixes this: the compiler substitutes the actual type at every
call site during inlining, so T behaves like a real, checkable type.
inline fun <reified T> List<*>.filterByType(): List<T> {
val result = mutableListOf<T>()
for (item in this) {
if (item is T) result.add(item) // only legal because T is reified
}
return result
}
fun main() {
val mixed = listOf(1, "two", 3, "four", 5.0)
val strings: List<String> = mixed.filterByType()
val ints: List<Int> = mixed.filterByType()
println(strings)
println(ints)
}
reified only works on inline functions — the compiler needs to paste the
function's body into each call site to know what concrete type to substitute
for T. A plain (non-inline) generic function has no way to recover T at
runtime, so value is T there is a compile error, not just a bad idea.
reified is an inline-only trick, not a generics upgrade
You can't store a reified type parameter in a class (only in an
inline fun), and you can't call an inline reified function through a
non-inlined layer of indirection (like a function reference) and expect
the substitution to still happen. It's a compile-time code-generation
tool, not a change to how the JVM handles generics underneath.
Cheat sheet¶
| Concept | Syntax | Meaning |
|---|---|---|
| Generic class/function | class Box<T>(...) / fun <T> f(...) |
Works with any type, checked at compile time |
| Upper bound | <T : Comparable<T>> |
Restricts T, unlocks that type's members |
| Covariance (producer) | class C<out T> |
C<Dog> is a subtype of C<Animal> |
| Contravariance (consumer) | class C<in T> |
C<Animal> is usable as C<Dog> |
| Star projection | List<*> |
"A List of something," read-only access |
| Reified type parameter | inline fun <reified T> |
T usable in is T checks at runtime |
How It Actually Works¶
Kotlin generics use type erasure, exactly like Java's — Box<T>
compiles to a single .class file with one field typed Object (or, if T
has an upper bound like Comparable<T>, typed as that bound), and every
Box<Int>, Box<String> at runtime is the same Box class with the same
bytecode; there is no Box$Int or Box$String generated. T get(): T
compiles to a method returning Object, with an invisible checkcast
inserted at each call site by the compiler where the result gets assigned
to a specific type — that's the "unchecked cast" every generics-erasure
language quietly does for you. This is precisely why you can't write T()
or check if (x is T) in a normal generic function: by the time the JVM
runs your code, the T bytecode needed to make that check has already been
erased down to Object, and there is no runtime record of what T was
supposed to be.
reified type parameters exist only because they're paired with inline.
An inline fun doesn't compile to a callable method at all in the usual
sense — the compiler literally copies the function's bytecode into every
call site that invokes it, substituting the caller's actual type argument
in place of T textually, the same way a C preprocessor macro would. So
list.filterByType<String>() doesn't erase String to Object because
there's no separate method being erased — the is T check becomes a
concrete is String check baked directly into the caller's own bytecode at
compile time. This is also why reified is forbidden outside inline
functions: without inlining, there's no call site to paste concrete
bytecode into, so the eraser would have nothing to substitute against.
out/in variance is enforced entirely by the Kotlin compiler's type
checker and has no separate bytecode representation on the JVM beyond
what Java's own wildcard generics (? extends T / ? super T) already
provide — when Kotlin generates a Java-visible signature for a
declaration-site-variant type, it actually emits Java wildcards
(Container<? extends Animal>) into the class file's generic-signature
metadata, so out/in is really "declare the variance once at the class,
and let the compiler emit the equivalent Java use-site wildcard everywhere
automatically" rather than a fundamentally new runtime capability.
Exercise¶
Write a generic class Stack<T> with push(item: T), pop(): T?, and
peek(): T?, backed by a MutableList<T> internally. Then write an inline
extension function fun <reified T> Stack<*>.countInstancesOf(): Int that
counts how many elements currently on the stack are of type T using an
is T check — this only compiles because T is reified. Test it by
pushing a mix of Int, String, and Double values onto a Stack<Any>
and confirming countInstancesOf<Int>(), countInstancesOf<String>(), and
countInstancesOf<Double>() each report the right count.