Skip to content
ioob.dev
Go back

Kotlin Part 7 — Generics

· 4 min read
Kotlin Series (7/12)
  1. Kotlin Beginner Part 1 — Variables and Types
  2. Kotlin Beginner Part 2 — Conditionals and Loops
  3. Kotlin Beginner Part 3 — Functions
  4. Kotlin Beginner Part 4 — Classes and Objects
  5. Kotlin Beginner Part 5 — Collections and Lambdas
  6. Kotlin Part 6 — Null Safety Advanced
  7. Kotlin Part 7 — Generics
  8. Kotlin Part 8 — sealed class and enum
  9. Kotlin Part 9 — Coroutines Basics
  10. Kotlin Part 10 — Coroutines Advanced
  11. Kotlin Part 11 — DSL and Advanced Functions
  12. Kotlin Part 12 — Practical Patterns
Table of contents

Table of contents

Why Generics

In Part 5, when we used listOf("Apple", "Banana"), you may have noticed that this list becomes type List<String>. The String inside the angle brackets is a generic type parameter.

What would happen without generics? You’d need a separate container for each type: StringList, IntList, UserList… endlessly. Generics provide a compile-time guarantee that “any type can be stored, but once decided, only that type is allowed.”

Generic Classes

Let’s start with the most basic form. Type parameters are declared inside angle brackets (<>).

class Box<T>(val item: T) {
    fun getContent(): T = item
    fun describe(): String = "Box contains: $item"
}

fun main() {
    val stringBox = Box("Hello")
    val intBox = Box(42)

    println(stringBox.getContent())  // Hello
    println(intBox.describe())       // Box contains: 42

    // stringBox.getContent() + 1    // Compile error — it's a String
    println(intBox.getContent() + 1) // 43
}

T in Box<T> is a conventional name — short for Type. When multiple type parameters are needed, the convention is to use names like T, R, K, V.

Thanks to type inference, you can write Box("Hello") instead of Box<String>("Hello"). The compiler infers T = String from the argument type.

Generic Functions

Type parameters can be attached to functions as well, not just classes.

fun <T> printAll(vararg items: T) {
    for (item in items) {
        println(item)
    }
}

fun <T> List<T>.secondOrNull(): T? {
    return if (size >= 2) this[1] else null
}

fun main() {
    printAll("A", "B", "C")
    // A
    // B
    // C

    val numbers = listOf(10, 20, 30)
    println(numbers.secondOrNull())  // 20

    val empty = emptyList<Int>()
    println(empty.secondOrNull())    // null
}

The type parameter is declared as fun <T> before the function name. It works with extension functions too, making it useful for building library-style utility functions.

Type Constraints — Upper Bound

If you accept any type, there’s not much you can do with it. All you know about T is that it’s Any?. Restricting it to subtypes of a specific type lets you use that type’s methods.

fun <T : Comparable<T>> findMax(list: List<T>): T {
    var max = list.first()
    for (item in list) {
        if (item > max) max = item
    }
    return max
}

fun main() {
    println(findMax(listOf(3, 1, 4, 1, 5, 9)))  // 9
    println(findMax(listOf("banana", "apple", "cherry")))  // cherry

    // findMax(listOf(Object(), Object()))  // Compile error — not Comparable
}

T : Comparable<T> is a constraint saying “T must be a type that implements Comparable.” This enables use of the > operator (which is actually compareTo).

where — Multiple Constraints

When you need two or more constraints on a type parameter, use where.

interface Printable {
    fun prettyPrint(): String
}

interface Loggable {
    fun log()
}

class Document(val title: String) : Printable, Loggable {
    override fun prettyPrint(): String = "[ $title ]"
    override fun log() = println("LOG: $title")
}

fun <T> processItem(item: T) where T : Printable, T : Loggable {
    println(item.prettyPrint())
    item.log()
}

fun main() {
    val doc = Document("Kotlin Generics")
    processItem(doc)
    // [ Kotlin Generics ]
    // LOG: Kotlin Generics
}

where T : Printable, T : Loggable means T must implement both interfaces. You can place multiple conditions on a single type parameter, forcing it to only use functionality at the intersection.

Covariance — out

The most confusing part of generics is variance. The core question is this: if Dog is a subtype of Animal, is List<Dog> also a subtype of List<Animal>?

open class Animal(val name: String)
class Dog(name: String) : Animal(name)
class Cat(name: String) : Animal(name)

Intuitively, you might think “of course it is.” But if you allow a List<Dog> to be assigned to a List<Animal> variable, then adding a Cat to it becomes possible. Type safety breaks.

Kotlin solves this problem with the out keyword.

// out — covariant (read-only)
interface Source<out T> {
    fun nextItem(): T       // Returning T is OK
    // fun addItem(item: T) // Compile error — accepting T is not allowed
}

class AnimalSource(private val animals: List<Animal>) : Source<Animal> {
    private var index = 0
    override fun nextItem(): Animal = animals[index++]
}

fun printNext(source: Source<Animal>) {
    println(source.nextItem().name)
}

fun main() {
    val dogs: Source<Dog> = object : Source<Dog> {
        override fun nextItem() = Dog("Buddy")
    }

    printNext(dogs)  // OK — Source<Dog> can be used as Source<Animal>
}

out T declares “this class only produces T, it never consumes it.” It promises to use T only as a return value, never as a parameter. Thanks to this promise, Source<Dog> can safely be used where Source<Animal> is expected.

Kotlin’s List<E> is declared with out E, which is why List<Dog> can be used like List<Animal> — it’s the same principle.

Diagramming the relationship between out and in makes the directionality clear. The type hierarchy goes downward, covariance allows the same direction in generic types, and contravariance allows the opposite direction.

flowchart TB
    subgraph Types[Type Hierarchy]
        Animal[Animal]
        Dog[Dog]
        Animal --> Dog
    end
    subgraph Covariant[out - Covariant]
        SA["Source&lt;Animal&gt;"]
        SD["Source&lt;Dog&gt;"]
        SD -->|Assignable| SA
    end
    subgraph Contravariant[in - Contravariant]
        KA["Sink&lt;Animal&gt;"]
        KD["Sink&lt;Dog&gt;"]
        KA -->|Assignable| KD
    end

Contravariance — in

in is the opposite of out. It declares “this class only consumes T, it never produces it.”

// in — contravariant (write-only)
interface Sink<in T> {
    fun send(item: T)       // Accepting T is OK
    // fun receive(): T     // Compile error — returning T is not allowed
}

fun feedAnimals(sink: Sink<Dog>) {
    sink.send(Dog("Buddy"))
}

fun main() {
    val animalSink: Sink<Animal> = object : Sink<Animal> {
        override fun send(item: Animal) {
            println("Received: ${item.name}")
        }
    }

    feedAnimals(animalSink)  // OK — Sink<Animal> can be used where Sink<Dog> is expected
}

Sink<Animal> can accept any animal, so naturally it can accept Dog too. That’s why Sink<Animal> can safely be placed where Sink<Dog> is needed. The direction is exactly the opposite of out.

Here’s an easy-to-remember summary.

KeywordDirectionAnalogyRepresentative Example
outProducerTake out onlyList<out E>, Source<out T>
inConsumerPut in onlyComparable<in T>, Sink<in T>

Java’s ? extends T corresponds to out T, and ? super T corresponds to in T. The Kotlin version is certainly easier to read.

Star Projection

When you don’t know or don’t care about the type argument, use *.

fun printListSize(list: List<*>) {
    println("List size: ${list.size}")
}

fun main() {
    printListSize(listOf(1, 2, 3))       // List size: 3
    printListSize(listOf("a", "b"))      // List size: 2
    printListSize(listOf(true, false))   // List size: 2
}

List<*> means “I don’t know what type of list this is, but I know it’s a list.” Elements come out as Any?, and adding elements is not safe, so it’s not allowed. Think of it as equivalent to Java’s List<?>.


Generics often start with just <T> and hit a wall at the variance concept. But the core is simple: out means take out only, in means put in only. Once you grasp this principle, everything else follows naturally.

The next part covers sealed classes and enums. Let’s explore Kotlin’s powerful way of representing state through the type system.

-> Part 8: sealed class and enum


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kotlin Part 6 — Null Safety Advanced
Next Post
Kotlin Part 8 — sealed class and enum