EthicsComputerCourses
Join training +91 90532 10052

Generics

Kotlin · Lesson 20 of 41 Intermediate 7 min read Updated

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.

Kotlin
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())
}
Output
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.

Kotlin
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
}
Output
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 <.

Kotlin
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'))
}
Output
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 T means the class only produces (returns) T. Then Crate<Mango> can be used where Crate<Fruit> is expected. List<out E> is declared this way.
  • in T means the class only consumes (accepts) T. Then a handler for any Fruit can be used where a handler for Mango is expected. Comparator<in T> is declared this way.
  • No keyword means invariant: MutableList<Mango> and MutableList<Fruit> are unrelated types.
Kotlin
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
}
Output
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.

Kotlin
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'))
}
Output
[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 Any instead of generics. class Box(val content: Any) forces casts everywhere and loses type safety. Use Box<T>.
  • Forgetting the constraint. fun <T> largest(items: List<T>) cannot use >; add T : Comparable<T>.
  • Expecting MutableList<Child> to be a MutableList<Parent>. Mutable collections are invariant on purpose. Use List<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) and in 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 reified parameters 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
Kotlin
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())
}
Output
[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?

2. Why is fun <T : Comparable<T>> largest(items: List<T>) able to use >?

3. Which is true for class Crate<out T>?

4. What does reified make possible?

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