Table of contents
- Why Generics
- Generic Classes
- Generic Functions
- Type Constraints — Upper Bound
- where — Multiple Constraints
- Covariance — out
- Contravariance — in
- Star Projection
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<Animal>"]
SD["Source<Dog>"]
SD -->|Assignable| SA
end
subgraph Contravariant[in - Contravariant]
KA["Sink<Animal>"]
KD["Sink<Dog>"]
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.
| Keyword | Direction | Analogy | Representative Example |
|---|---|---|---|
out | Producer | Take out only | List<out E>, Source<out T> |
in | Consumer | Put in only | Comparable<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.




Loading comments...