Generics in Kotlin
Generics let you write a single class or function that works with many different types while the compiler still checks that you’re using it correctly. Instead of writing a separate StringBox, IntBox, and UserBox, you write one Box<T> and let the type parameter T stand in for whatever type you plug in. Kotlin’s generics build on the same erasure-based model as Java’s, but add cleaner syntax, declaration-site variance (in/out), and a way to bypass erasure entirely with reified type parameters — something Java cannot do at all.
Overview: How Generics Work
A generic declaration introduces one or more type parameters — placeholders like T, K, V — inside angle brackets. When code later uses that class or function with a concrete type, such as Box<String>, the compiler substitutes String everywhere T appears and checks every operation against that substitution. If you try to pass an Int where the type parameter has been fixed to String, the code simply won’t compile. This is the entire point: generics move a category of bugs that would otherwise surface as a ClassCastException at runtime into compile-time type errors, long before the program ever runs.
Under the hood, Kotlin compiles to JVM bytecode, and the JVM’s generics are implemented via type erasure: at runtime, a List<String> and a List<Int> are both just a List — the specific type argument is discarded during compilation and only used for compile-time checking. This is why you cannot normally write value is T inside a generic function: by the time that code runs, T no longer exists as a distinct runtime concept. Kotlin gives you an escape hatch for this specific case — inline functions with reified type parameters, covered below — but ordinary generic classes and functions are erased just like in Java.
By default, generic types in Kotlin are invariant: Box<String> is not a subtype of Box<Any>, even though String is a subtype of Any. This looks restrictive at first, but it protects you from a real hole: if Box<String> were freely usable as Box<Any>, code could insert an Int into what is really a Box<String> and blow up later. Kotlin lets you opt into safe variance explicitly with the out and in modifiers (declaration-site variance), which is more ergonomic than Java’s use-site wildcards (? extends T / ? super T) sprinkled at every call site.
Syntax
The general forms for a generic class and a generic function:
class ClassName<T>(val value: T) {
fun get(): T = value
}
fun <T> functionName(param: T): T {
return param
}
// with an upper bound
fun <T : Comparable<T>> boundedFunction(a: T, b: T): T { ... }
// with declaration-site variance
class Producer<out T>(private val item: T) {
fun produce(): T = item
}
| Part | Meaning |
|---|---|
<T> |
Declares a type parameter named T (any name works; T, K, V, E are conventional). |
T : Comparable<T> |
An upper bound: only types that implement Comparable<T> may be used as the type argument. |
out T |
Covariance: T may only appear in “output” positions (return types), making Producer<String> a subtype of Producer<Any>. |
in T |
Contravariance: T may only appear in “input” positions (parameter types), the mirror image of out. |
reified T |
Only legal on an inline function’s type parameter; keeps T available as a real type at each call site. |
Examples
Example 1: A basic generic class
class Box<T>(val content: T) {
fun describe(): String = "Box contains: $content"
}
fun main() {
val intBox = Box(42)
val stringBox = Box("Hello")
println(intBox.describe())
println(stringBox.describe())
}
Output:
Box contains: 42
Box contains: Hello
Kotlin infers T from the constructor argument, so Box(42) becomes Box<Int> and Box("Hello") becomes Box<String> without any explicit type argument. Both share the exact same compiled class; only the compile-time type-checking differs per usage.
Example 2: A generic function with an upper bound
fun <T : Comparable<T>> findMax(items: List<T>): T {
var max = items[0]
for (item in items) {
if (item > max) {
max = item
}
}
return max
}
fun main() {
val numbers = listOf(3, 7, 2, 9, 4)
val words = listOf("banana", "apple", "cherry")
println("Max number: ${findMax(numbers)}")
println("Max word: ${findMax(words)}")
}
Output:
Max number: 9
Max word: cherry
The bound T : Comparable<T> tells the compiler that whatever type is plugged in must support the > operator (via compareTo). Without that bound, this function wouldn’t compile at all, because the compiler has no way to know that a generic, unbounded T supports comparison — see Common Mistakes below.
Example 3: Declaration-site variance with out
interface Container<out T> {
fun get(): T
}
class SimpleContainer<T>(private val item: T) : Container<T> {
override fun get(): T = item
}
fun printContainerContent(container: Container<Any>) {
println("Content: ${container.get()}")
}
fun main() {
val stringContainer: Container<String> = SimpleContainer("Kotlin")
printContainerContent(stringContainer)
val intContainer: Container<Int> = SimpleContainer(100)
printContainerContent(intContainer)
}
Output:
Content: Kotlin
Content: 100
Because Container is declared with out T, it only ever produces values of T (through get()), never consumes them as parameters. That makes it safe for Container<String> to be treated as a Container<Any>, so both stringContainer and intContainer can be passed to a function that only knows about Container<Any>.
Example 4: Beating type erasure with reified
inline fun <reified T> isTypeOf(value: Any): Boolean {
return value is T
}
fun main() {
println(isTypeOf<String>("Hello"))
println(isTypeOf<Int>("Hello"))
println(isTypeOf<String>(42))
}
Output:
true
false
false
Marking the function inline and its type parameter reified tells the compiler to paste the function body directly into each call site, substituting the real type argument as literal source code. That is why value is T is legal here but illegal in an ordinary generic function — by the time this code is compiled, there is no T left at all, only value is String or value is Int written straight into the caller.
How It Works Step by Step
Take the call findMax(numbers) from Example 2. First, the compiler infers T = Int from the argument’s type, List<Int>. Second, it checks that Int satisfies the bound Comparable<Int> — it does, so compilation proceeds; if you passed a type that isn’t Comparable, this step is where compilation would fail. Third, inside the function body every reference to T is treated, for checking purposes, as the substituted type, so item > max type-checks as an Int comparison. Finally, when the code is compiled to JVM bytecode, the generic type information is erased: the compiled findMax method operates on plain Comparable references, and the JVM never sees “Int” as a generic argument at all — only the compiler’s checks, done ahead of time, guaranteed it was safe.
Common Mistakes
Mistake 1: Assuming generics are covariant by default
class Box<T>(val content: T)
fun printBox(box: Box<Any>) {
println(box.content)
}
fun main() {
val stringBox: Box<String> = Box("Kotlin")
printBox(stringBox) // Type mismatch: does not compile
}
This fails to compile because Box<T> is invariant — Box<String> is simply not a Box<Any> as far as the compiler is concerned, even though String is an Any. The fix is to declare the class covariant with out when it only ever produces T values (never accepts one as a parameter):
class Box<out T>(val content: T)
fun printBox(box: Box<Any>) {
println(box.content)
}
fun main() {
val stringBox: Box<String> = Box("Kotlin")
printBox(stringBox)
}
Output:
Kotlin
Mistake 2: Checking a generic type parameter at runtime
fun <T> isTypeOf(value: Any): Boolean {
return value is T // Cannot check for instance of erased type: T
}
Because generics are erased, an ordinary generic function has no runtime record of what T was; this line simply won’t compile. As shown in Example 4, the fix is to make the function inline and the type parameter reified, which preserves the type at each call site instead of erasing it.
Mistake 3: Forgetting an upper bound before comparing values
fun <T> findMax(items: List<T>): T {
var max = items[0]
for (item in items) {
if (item > max) { // Operator '>' cannot be applied to 'T' and 'T'
max = item
}
}
return max
}
An unbounded T could be absolutely anything, including types with no ordering at all, so the compiler refuses to let you use > on it. Adding T : Comparable<T>, as in Example 2, restricts T to types that support comparison and makes the operator legal.
Best Practices
- Add an upper bound (
T : SomeType) whenever your generic code needs to call methods onTbeyond whatAnyprovides — it documents the requirement and catches misuse at the call site. - Use
outfor read-only, producer-style generic types (containers, results, lists you only read from), andinfor consumer-style types (comparators, callbacks that only accept values) — this is the “producerout, consumerin” rule (PECS). - Reach for
reifiedonly inside small,inlineutility functions; because the body is copied to every call site, large reified functions bloat compiled bytecode. - Prefer Kotlin’s built-in generic collection interfaces (
List,Map,Set, all already declared with sensible variance) instead of designing your own container hierarchy from scratch. - Give type parameters short, conventional names (
T,Efor element,K/Vfor key/value) unless a longer name genuinely improves clarity in a complex signature. - Remember a
valholding a generic mutable collection, likeval items: MutableList<T>, still allows mutating its contents —valonly prevents reassigning the reference itself.
Practice Exercises
- Write a generic function
<T> firstOrNull(list: List<T>): T?that returns the first element of a list, ornullif the list is empty, without using!!. - Write a generic class
Pair2<A, B>(val first: A, val second: B)with a functionswap(): Pair2<B, A>that returns a new pair with the values swapped. Test it with anInt/Stringpair and print both pairs. - Write an
inline fun <reified T> countOfType(items: List<Any>): Intthat returns how many elements of a mixed list are instances ofT. GivenlistOf(1, "a", 2, "b", 3), calling it withT = Intshould print3.
Summary
- Generics let one class or function work with many types while the compiler enforces type safety, catching mismatches before the program runs.
- Generic types are erased at runtime on the JVM, and are invariant by default —
Box<String>is not automatically aBox<Any>. - Upper bounds (
T : Comparable<T>) let generic code call methods that a plain, unconstrainedTwouldn’t support. - Declaration-site variance (
outfor producers,infor consumers) makes subtyping between generic types safe and explicit. inlinefunctions withreifiedtype parameters are Kotlin’s unique way to keep a type parameter available at runtime, bypassing erasure for that specific call.
