Generics
You have been using generics since the collections lessons: List<String>, Map<String, Int>, mutableListOf<Order>(). The part in angle brackets is a type argument. It tells the compiler what the collection holds, so it can stop you from putting a number into a list of names, and so you never need to cast when you take items out.
In this lesson you will write your own generic classes and functions, restrict them with constraints, and understand the out and in keywords you will meet in library code. You will finish with reified, which lets inline functions use type information at run time.
Generic classes
A generic class has a type parameter in angle brackets after its name, usually a single capital letter such as T (type), E (element), K and V (key and value) or R (result). Inside the class, T stands for whatever type the user chooses.
class Box<T>(val content: T) {
fun describe() = "Box with $content"
}
fun main() {
val sweets = Box("Kaju Katli")
val count = Box(12)
val price = Box<Double>(2.5)
println(sweets.describe())
println(count.content + 3)
println(price.content)
println(sweets.content.uppercase())
}Box with Kaju Katli 15 2.5 KAJU KATLI
Kotlin infers Box<String> and Box<Int> from the constructor arguments, so you rarely write the type argument yourself. Because the compiler knows count.content is an Int, count.content + 3 works, and sweets.content.uppercase() works because it knows sweets.content is a String. One class, many types, no casts.
Here is a more useful example: a stack (last in, first out), like the back button history in an app.
class Stack<T> {
private val items = mutableListOf<T>()
fun push(item: T) {
items.add(item)
}
fun pop(): T? = items.removeLastOrNull()
fun peek(): T? = items.lastOrNull()
val size: Int get() = items.size
}
fun main() {
val history = Stack<String>()
history.push("Home")
history.push("Trains")
history.push("Booking")
println(history.pop())
println(history.peek())
println(history.size)
// history.push(42) // does not compile: this stack holds Strings
}Booking Trains 2
Stack<String>() needs the type argument because there is no value to infer it from. pop() returns T? so that popping an empty stack gives null instead of crashing.
Generic functions and constraints
A function can have its own type parameters, written before the function name: fun <T> name(...). Often you need the type to support certain operations. A constraint (or upper bound) after a colon says what T must be. T : Comparable<T> means "any type whose values can be compared with each other", which allows > and <.
fun <T : Comparable<T>> largest(items: List<T>): T? {
if (items.isEmpty()) return null
var best = items[0]
for (item in items) {
if (item > best) best = item
}
return best
}
fun <T> joinAll(vararg items: T): String = items.joinToString(" | ")
fun main() {
println(largest(listOf(42, 97, 63)))
println(largest(listOf("Pune", "Agra", "Shimla")))
println(largest(listOf<Double>()))
println(joinAll("tea", "coffee"))
println(joinAll(1, 2.5, 'x'))
}97 Shimla null tea | coffee 1 | 2.5 | x
largest works for numbers and strings (strings compare alphabetically), but calling it with a list of Box objects would not compile, because Box is not Comparable. For several constraints, use a where clause: fun <T> save(item: T) where T : Comparable<T>, T : Serializable.
By default a type parameter may be nullable (its default bound is Any?). Write <T : Any> when null must not be allowed.
Variance: out and in
Here is a puzzle. A Mango is a Fruit. Is a list of mangoes a list of fruits? For a read-only List, yes: if you can only take items out, every item you get is a fruit. For a MutableList, no: if a MutableList<Mango> could be treated as MutableList<Fruit>, someone could add a Banana to your mangoes.
Kotlin expresses this with variance keywords on type parameters:
out Tmeans the class only produces (returns)T. ThenCrate<Mango>can be used whereCrate<Fruit>is expected.List<out E>is declared this way.in Tmeans the class only consumes (accepts)T. Then a handler for anyFruitcan be used where a handler forMangois expected.Comparator<in T>is declared this way.- No keyword means invariant:
MutableList<Mango>andMutableList<Fruit>are unrelated types.
open class Fruit(val name: String)
class Mango : Fruit("Mango")
class Banana : Fruit("Banana")
class Crate<out T : Fruit>(private val items: List<T>) {
fun first(): T = items.first()
}
fun printFirst(crate: Crate<Fruit>) {
println("First fruit: ${crate.first().name}")
}
fun main() {
val mangoes: Crate<Mango> = Crate(listOf(Mango(), Mango()))
printFirst(mangoes)
val fruits: List<Fruit> = listOf(Mango(), Banana())
println(fruits.map { it.name })
val basket: MutableList<Mango> = mutableListOf(Mango())
// val asFruits: MutableList<Fruit> = basket // does not compile
}First fruit: Mango [Mango, Banana]
Because Crate is declared out T, a Crate<Mango> is accepted by printFirst. The compiler enforces the promise: inside Crate, T may appear as a return type but not as a parameter of a public function. A useful memory aid is "producer out, consumer in".
Star projections, type erasure and reified
When you do not care about the element type, use a star projection: List<*> means "a list of something". You can read items from it (as Any?) but you cannot add to it.
On the JVM, type arguments are erased at run time: a List<String> and a List<Int> are both just List. That is why if (x is List<String>) does not compile. An inline function can mark its type parameter reified, which keeps the real type available, because the compiler copies the function body into each call site with the actual type filled in.
inline fun <reified T> List<Any>.onlyOf(): List<T> = filterIsInstance<T>()
fun printSize(list: List<*>) {
println("Size: ${list.size}")
}
fun main() {
val mixed: List<Any> = listOf("Aarav", 21, "Pune", 4.5, 7)
println(mixed.onlyOf<String>())
println(mixed.onlyOf<Int>())
printSize(mixed)
printSize(listOf('a', 'b'))
}[Aarav, Pune] [21, 7] Size: 5 Size: 2
Inside a reified function you can write item is T and T::class, which is impossible with ordinary type parameters. You will meet reified functions in libraries for JSON parsing and dependency injection, where you write something like decode<Order>(text).
Common mistakes
- Using
Anyinstead of generics.class Box(val content: Any)forces casts everywhere and loses type safety. UseBox<T>. - Forgetting the constraint.
fun <T> largest(items: List<T>)cannot use>; addT : Comparable<T>. - Expecting
MutableList<Child>to be aMutableList<Parent>. Mutable collections are invariant on purpose. UseList<Parent>for read-only access. - Checking generic types at run time.
x is List<String>is not allowed because of erasure; use a reified inline function or check items one by one.
Key points
- Generic classes (
class Box<T>) and functions (fun <T> f()) work with many types safely, without casts. - Type arguments are usually inferred; write them when there is nothing to infer from.
- Constraints such as
T : Comparable<T>limit which types are allowed and unlock their operations. out T(producer) andin T(consumer) control how generic types relate; no keyword means invariant.List<*>is a star projection for "a list of unknown type".- Type arguments are erased at run time, except for
reifiedparameters of inline functions.
Exercise
Write a generic class Leaderboard<T : Comparable<T>> that stores entries as pairs of a player name and a score of type T. Add add(name: String, score: T), top(n: Int): List<String> (names with the highest scores first) and best(): Pair<String, T>?. Use it once with Int cricket runs and once with Double quiz points.
Show solution
class Leaderboard<T : Comparable<T>> {
private val entries = mutableListOf<Pair<String, T>>()
fun add(name: String, score: T) {
entries.add(name to score)
}
fun top(n: Int): List<String> =
entries.sortedByDescending { it.second }.take(n).map { it.first }
fun best(): Pair<String, T>? = entries.maxByOrNull { it.second }
}
fun main() {
val runs = Leaderboard<Int>()
runs.add("Aarav", 54)
runs.add("Kabir", 102)
runs.add("Rohan", 77)
println(runs.top(2))
println(runs.best())
val points = Leaderboard<Double>()
points.add("Isha", 8.75)
points.add("Meera", 9.1)
println(points.best())
}[Kabir, Rohan] (Kabir, 102) (Meera, 9.1)
The constraint T : Comparable<T> is what allows sortedByDescending and maxByOrNull to compare scores of an unknown type.
Quick Quiz
1. In class Box<T>(val content: T), what is T?
T is a placeholder for a type chosen by the user, such as Box<String>.
2. Why is fun <T : Comparable<T>> largest(items: List<T>) able to use >?
The upper bound Comparable<T> provides compareTo, which > uses.
3. Which is true for class Crate<out T>?
out makes the class a producer of T, so it is covariant.
4. What does reified make possible?
Normally type arguments are erased; reified parameters of inline functions keep them.
Learn Kotlin with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
