Chapter 20: Generics
Generics — this is one of the most powerful, elegant, and brain-expanding features in Kotlin (and modern programming in general).
Generics let you write type-safe, reusable code that works with any type — while still getting full compile-time safety and no runtime overhead. Once you master generics, you’ll feel like you’ve unlocked a superpower — especially when working with collections, repositories, mappers, or any reusable logic.
We’re going to go super slowly, like we’re sitting together in a cozy Mumbai café — I’ll explain every concept with real-life analogies, complete runnable examples, step-by-step breakdowns, tables, common mistakes with fixes, and tons of practical code you can copy-paste and run right now.
Let’s dive in! ☕🚀
1. Generic Classes & Functions – The Basics
Generic classes and generic functions use type parameters (usually T, E, K, V, R, etc.) to represent any type.
Example 1: Generic Class – A Simple Box
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
class Box<T>(var content: T) { fun getContent(): T = content fun setContent(newContent: T) { content = newContent } } fun main() { // Box of String val stringBox = Box("Hello Webliance!") println(stringBox.getContent()) // Hello Webliance! // Box of Int val intBox = Box(2026) println(intBox.getContent()) // 2026 // Box of custom class data class Student(val name: String) val studentBox = Box(Student("Amit")) println(studentBox.getContent().name) // Amit } |
Step-by-step:
- class Box<T> → T is a placeholder for any type
- When you create Box<String>, T becomes String
- The compiler ensures type safety — you can’t put Int in a Box<String>
Example 2: Generic Function
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
fun <T> printItem(item: T) { println("Item: $item") } fun main() { printItem("Kotlin") // T = String printItem(42) // T = Int printItem(listOf(1,2,3)) // T = List<Int> } |
Generic function syntax:
- <T> before the function name (type parameter declaration)
- Can be used in parameters, return type, etc.
2. Variance – out (Covariant) vs in (Contravariant)
Variance answers the question: “If I have a Box<Cat>, can I use it as a Box<Animal>?”
Kotlin uses declaration-site variance (you decide once when writing the class/function).
| Variance | Keyword | Meaning | Safe for | Example Use Case | Real-life Analogy |
|---|---|---|---|---|---|
| Covariant (out) | out T | Producer — you only read T | Reading (get) | List<T>, Sequence<T> | A fruit basket: if it contains apples, you can treat it as a fruit basket (Apple is a Fruit) |
| Contravariant (in) | in T | Consumer — you only write T | Writing (set) | Comparator<T>, Consumer<T> | A trash bin: if it accepts fruits, it can accept apples (more general type is safe) |
| Invariant (default) | T | Neither read nor write safely | Both | MutableList<T>, Array<T> | A locked box: only works with exact type |
Example 1: Covariant (out) – Reading is safe
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class Producer<out T>(private val item: T) { fun get(): T = item // fun set(value: T) { } // ERROR! Not safe with out } fun main() { val catProducer = Producer(Cat()) val animalProducer: Producer<Animal> = catProducer // Safe! Covariant println(animalProducer.get()) // Cat } open class Animal class Cat : Animal() |
Why safe? You can only get items → even if you treat it as Producer<Animal>, you’ll only get Animals (Cats are Animals).
Example 2: Contravariant (in) – Writing is safe
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class Consumer<in T> { fun consume(value: T) { println("Consumed: $value") } // fun get(): T // ERROR! Not safe with in } fun main() { val animalConsumer = Consumer<Animal>() val catConsumer: Consumer<Cat> = animalConsumer // Safe! Contravariant catConsumer.consume(Cat()) // OK } |
Why safe? You can only put items in → even if you treat it as Consumer<Cat>, you can safely put a Cat (because it accepts Animals, and Cat is Animal).
Example 3: Invariant (default) – Neither safe
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Box<T>(var item: T) { fun get(): T = item fun set(value: T) { item = value } } fun main() { val catBox = Box(Cat()) // val animalBox: Box<Animal> = catBox // ERROR! Invariant // val catBox2: Box<Cat> = animalBox // ERROR! Invariant } |
Rule of thumb (PECS – Producer Extends, Consumer Super):
- If you produce (get/output) T → use out T (covariant)
- If you consume (set/input) T → use in T (contravariant)
- If you do both → leave invariant (T)
3. Star Projection (*) – When You Don’t Care About the Type
Star projection (*) means “I don’t know/care what the type parameter is”.
Use cases:
- When you only read and don’t care about the type
- When you want to accept any generic type
Example:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
fun printCollection(collection: Collection<*>) { for (item in collection) { println(item) } // collection.add(???) // ERROR! Cannot add – unknown type } fun main() { printCollection(listOf(1, 2, 3)) // OK printCollection(listOf("A", "B", "C")) // OK printCollection(setOf(true, false)) // OK } |
Star projection rules:
- Collection<*> → you can read as Any?
- MutableList<*> → you cannot read or write (only size, isEmpty, etc.)
4. Reified Type Parameters – Knowing the Type at Runtime
Normally, generics are erased at runtime (type parameters disappear — T becomes Any?).
reified lets you keep the type information at runtime — only possible in inline functions.
Syntax:
|
0 1 2 3 4 5 6 |
inline fun <reified T> isInstance(obj: Any): Boolean = obj is T |
Example:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
inline fun <reified T> printTypeInfo(item: Any) { println("Item is ${T::class.simpleName}: ${item is T}") if (item is T) { println("Value: $item") } } fun main() { printTypeInfo<String>("Hello") // Item is String: true printTypeInfo<Int>(42) // Item is Int: true printTypeInfo<Double>(3.14) // Item is Double: true } |
Very common use case: Generic repository / parser functions that need to know the exact type.
Limitation: reified only works in inline functions — the compiler inlines the code and keeps the type info.
Quick Recap Table (Your Cheat Sheet)
| Feature | Syntax / Example | Key Benefit |
|---|---|---|
| Generic class | class Box<T>(val item: T) | Type-safe reusable container |
| Generic function | fun <T> print(item: T) | Flexible for any type |
| Covariant (out) | class Producer<out T> | Safe for reading (get) |
| Contravariant (in) | class Consumer<in T> | Safe for writing (set) |
| Star projection | Collection<*> | Accept any type (read as Any?) |
| Reified | inline fun <reified T> … | Know type at runtime (no erasure) |
Common Newbie Mistakes & Fixes
| Mistake | Problem | Fix |
|---|---|---|
| Using out when you need to write | Compile error when setting | Use invariant (T) or in |
| Using in when you need to read | Can’t safely read (gets Any?) | Use out or invariant |
| Forgetting reified in inline function needing type info | Type erasure – is T fails | Add reified to type parameter |
| Using * and trying to add elements | Compile error – unknown type | Use in T if you need to write |
| Not using out on read-only collections | Less flexible | Mark List<out T> (already done in stdlib) |
Homework for You (Let’s Make It Fun!)
- Basic Create generic class Pair<T, U>(val first: T, val second: U) with swap() function that returns reversed pair.
- Medium Create covariant generic class Producer<out T>(val item: T) → show you can assign Producer<Cat> to Producer<Animal>.
- Advanced Create contravariant generic class Consumer<in T> with consume(value: T) → show Consumer<Animal> can be used as Consumer<Cat>.
- Fun Create reified inline function inline fun <reified T> List<*>.countOfType() → returns how many elements are of type T.
- Challenge Fix this code:
Kotlin012345678fun <T> printFirst(list: List<T>) {println(list.first() is String) // Always false – erasure!}
You’ve just mastered Kotlin’s generics system — now you can write type-safe, flexible, and high-performance code!
