Kotlin Generics Explained
Kotlin generics are safer than Java's — declaration-site variance lets Charlie decide covariance at definition time instead of repeating wildcards at every use site.
1. What You'll Learn
- Variance:
out(covariance) vsin(contravariance) - Use-site variance: type projection
- Star projection:
*safe access to unknown generics reified: inline functions preserve generic type information- Charlie in action:
ReadOnlyRepository<out T>/OrderFilter<in T>
2. A Real Architect's Story
(1) Pain Point: Java Generic Wildcard Hell
Charlie writes List<? extends Order> for read-only order lists and List<? super Order> for write-only lists in Java. Every use site requires repeating wildcards, making code nearly unreadable.
(2) Kotlin Declaration-Site Variance Solution
JAVA
// Java: repeat wildcard at every use site
void process(List<? extends Order> orders) { }
void addAll(List<? super Order> target) { }
// Kotlin: declare variance once at definition site
class Repository<out T : Order> { // Covariant at definition
fun getAll(): List<T> // T only appears in 'out' position
}
// No wildcards needed at use site!
Declaration-site variance: define once, use infinitely. Java use-site variance: repeat at every call site.
3. Generics Basics
(1) Generic Classes and Functions
KOTLIN
// Generic class
class Box<T>(val value: T) {
fun unwrap(): T = value
}
val intBox = Box(42) // T inferred as Int
val strBox = Box("Hello") // T inferred as String
// Generic function
fun <T> singletonList(item: T): List<T> = listOf(item)
// Generic with type constraint
fun <T : Comparable<T>> maxOf(a: T, b: T): T = if (a > b) a else b
// Multiple constraints
fun <T> process(item: T) where T : CharSequence, T : Comparable<T> {
// T is both CharSequence and Comparable
}
(2) The Variance Problem: Why We Need out/in
KOTLIN
// This seems reasonable but is a TYPE ERROR
val strings: List<String> = listOf("Hello", "World")
// val objects: List<Any> = strings // ERROR in Kotlin (OK in Java arrays)
// Why? If allowed:
// objects.add(42) // Would corrupt the String list!
// strings[0].length // ClassCastException at runtime
4. Declaration-Site Variance
(1) out (Covariance): Producer Only, Never Consumer
KOTLIN
// Producer: T only appears in 'out' position (return type)
interface Repository<out T> {
fun findById(id: String): T
fun findAll(): List<T>
// fun save(item: T) // ERROR: T in 'in' position
}
// Now this works:
val orderRepo: Repository<Order> = OrderRepositoryImpl()
val anyRepo: Repository<Any> = orderRepo // OK! Covariant
(2) in (Contravariance): Consumer Only, Never Producer
KOTLIN
// Consumer: T only appears in 'in' position (parameter type)
interface Filter<in T> {
fun test(item: T): Boolean
fun filter(items: List<T>): List<T>
// fun getResult(): T // ERROR: T in 'out' position
}
// Now this works:
val orderFilter: Filter<Order> = object : Filter<Order> {
override fun test(item: Order) = item.total > 1_000
override fun filter(items: List<Order>) = items.filter { test(it) }
}
val anyFilter: Filter<Any> = orderFilter // Cannot assign - contravariant direction
(3) Variance Rules at a Glance
| Modifier | Meaning | T Appears In | Subtype Direction | Analog |
|---|---|---|---|---|
out |
Covariant | Only in return position | Producer<Sub> is subtype of Producer<Base> |
Java ? extends |
in |
Contravariant | Only in parameter position | Consumer<Base> is subtype of Consumer<Sub> |
Java ? super |
| None | Invariant | Any position | No subtype relationship | Java T |
(4) Variance Flow Diagram
flowchart TD
subgraph "Covariant (out)"
A1[Producer of Sub] -->|"IS-A"| A2[Producer of Base]
end
subgraph "Contravariant (in)"
B1[Consumer of Base] -->|"IS-A"| B2[Consumer of Sub]
end
subgraph "Invariant"
C1[Box of Sub] -.->|"NOT IS-A"| C2[Box of Base]
end
5. Use-Site Variance (Type Projection)
When you can't decide variance at the declaration site, project it at the use site.
KOTLIN
// Use-site projection: when declaration-site is invariant
fun copyFrom(source: Array<out Any>, dest: Array<in Any>) {
for (i in source.indices) {
dest[i] = source[i]
}
}
// Equivalent to Java:
// void copyFrom(Object[] source, Object[] dest)
(1) Use-Site Variance Comparison
| Scenario | Java | Kotlin |
|---|---|---|
| Read-only parameter | <? extends T> |
<out T> |
| Write-only parameter | <? super T> |
<in T> |
| Declaration covariant | Not supported | out T |
| Declaration contravariant | Not supported | in T |
6. Star Projection
When the generic type is unknown, use star projection for safe access.
KOTLIN
// List<*> = list of unknown type (safe to read, not write)
val unknownList: List<*> = listOf("Hello", 42, 3.14)
val first: Any? = unknownList[0] // OK - returns Any?
// unknownList.add("New") // ERROR - can't write
// Comparison
fun printSize(list: List<*>) {
println("Size: ${list.size}") // OK - size doesn't depend on T
}
// Array<*> is different
val array: Array<*> = arrayOf("A", "B")
// array[0] = "C" // ERROR - can't write to Array<*>
val elem: Any? = array[0] // OK - can read
(1) Star Projection Rules
| Type | Star Projection Equivalent | Readable | Writable |
|---|---|---|---|
Foo<out T> |
Foo<out Any?> |
✅ (Any?) | ❌ |
Foo<in T> |
Foo<in Nothing> |
❌ | ✅ (Nothing = unwritable) |
Foo<T> (invariant, T upper bound) |
Foo<out Any?> |
✅ (Any?) | ❌ |
Foo<T> (T upper bound T : Upper) |
Foo<out Upper> |
✅ (Upper) | ❌ |
7. reified Type Parameters
(1) Type Erasure and reified
KOTLIN
// Normal generic: type is erased at runtime
fun <T> isA(value: Any): Boolean = value is T // ERROR: Cannot check for instance of erased type
// reified: inline function preserves type at call site
inline fun <reified T> isA(value: Any): Boolean = value is T // OK!
// Usage
isA<String>("Hello") // true
isA<Int>(42) // true
isA<Int>("42") // false
// Practical: filter by type
inline fun <reified T> Iterable<*>.filterIsInstance(): List<T> {
return filter { it is T }.map { it as T }
}
(2) Practical reified Use Cases
KOTLIN
// JSON deserialization without Class parameter
inline fun <reified T> Json.decodeFromString(json: String): T
// Instead of:
// json.decodeFromString(Order::class.java, jsonString)
// Just:
// json.decodeFromString<Order>(jsonString)
8. Complete Example: Type-Safe Order Repository
KOTLIN
// ============================================
// OrderProcessor - Type-Safe Repository with Generics
// Feature: Generic repository with variance
// ============================================
open class Order(val id: String, val total: Double)
class BulkOrder(id: String, total: Double, val minQty: Int) : Order(id, total)
class PriorityOrder(id: String, total: Double, val priority: String) : Order(id, total)
// Covariant repository: only produces T
interface ReadOnlyRepository<out T : Order> {
fun findById(id: String): T?
fun findAll(): List<T>
}
// Contravariant writer: only consumes T
interface WriteRepository<in T : Order> {
fun save(item: T)
fun saveAll(items: List<T>)
}
// Full repository: invariant (both reads and writes)
interface OrderRepository<T : Order> : ReadOnlyRepository<T>, WriteRepository<T>
// In-memory implementation
class InMemoryOrderRepository<T : Order> : OrderRepository<T> {
private val storage = mutableMapOf<String, T>()
override fun findById(id: String): T? = storage[id]
override fun findAll(): List<T> = storage.values.toList()
override fun save(item: T) { storage[item.id] = item }
override fun saveAll(items: List<T>) { items.forEach { save(it) } }
}
// Generic filter with contravariance
class OrderFilter<in T : Order>(private val predicate: (T) -> Boolean) {
fun test(item: T): Boolean = predicate(item)
fun filter(items: List<T>): List<T> = items.filter(predicate)
}
inline fun <reified T : Order> List<Order>.filterByType(): List<T> {
return this.filterIsInstance<T>()
}
fun main() {
val orders = listOf(
Order("ORD-001", 299.99),
BulkOrder("ORD-002", 5_000.00, 100),
PriorityOrder("ORD-003", 15_000.00, "VIP"),
BulkOrder("ORD-004", 8_000.00, 50),
PriorityOrder("ORD-005", 2_500.00, "GOLD")
)
// Covariant: BulkOrder repo assignable to Order repo
val bulkRepo: ReadOnlyRepository<BulkOrder> = InMemoryOrderRepository()
val orderRepo: ReadOnlyRepository<Order> = bulkRepo // OK: covariant
// reified: filter by runtime type
val bulkOrders: List<BulkOrder> = orders.filterByType<BulkOrder>()
println("Bulk orders: ${bulkOrders.map { it.id }}")
val priorityOrders: List<PriorityOrder> = orders.filterByType<PriorityOrder>()
println("Priority orders: ${priorityOrders.map { it.id }}")
// Generic filter
val highValueFilter = OrderFilter<Order> { it.total > 1_000 }
val highValue = highValueFilter.filter(orders)
println("High value orders: ${highValue.map { it.id }}")
}
Output:
TEXT
Bulk orders: [ORD-002, ORD-004]
Priority orders: [ORD-003, ORD-005]
High value orders: [ORD-002, ORD-003, ORD-004, ORD-005]
❓ FAQ
Q Can out and in be used on the same type parameter simultaneously?
A No. A type parameter can only be out, in, or invariant. If T appears in both input and output positions, it must be invariant.
Q Do Kotlin generics still exist at runtime?
A Mostly erased (same as Java). But inline + reified can preserve type information. Inline functions are expanded at the call site, so type info is available at compile time.
Q What's the difference between star projection
* and Any?A
List<*> means "element type unknown", readable only as Any?; List<Any> means "element type is Any", you can add any value. The former is safer.Q When to use declaration-site variance vs use-site?
A Prefer declaration-site variance — decide at API design time whether T is read-only (out) or write-only (in). Use-site variance is a last resort.
Q Does reified have performance overhead?
A No additional runtime overhead. Since inline functions are expanded at compile time, reified type checks happen at compile time with no extra runtime work.
Q What's the difference between
<T : Upper> and where T : Upper?A Single constraint uses
<T : Upper> shorthand; multiple constraints must use the where syntax.📖 Summary
out(covariance): T only appears in return position,Producer<Sub>is subtype ofProducer<Base>in(contravariance): T only appears in parameter position,Consumer<Base>is subtype ofConsumer<Sub>- Declaration-site variance defines once; use-site variance (projection) is an on-demand override
- Star projection
*is for unknown generic scenarios — safe but limited reifiedpreserves generic type info in inline functions, enabling runtime type checks- Variance mnemonic: Producer → out, Consumer → in
📝 Exercises
- Beginner (⭐): Implement a generic
Box<T>and test assignment compatibility with different type arguments. Hint: canBox<Int>be assigned toBox<Any>? - Intermediate (⭐⭐): Define
ReadOnlyRepository<out T>andWriteRepository<in T>, demonstrate covariance and contravariance assignment relationships. Hint:List<BulkOrder>→List<out Order> - Advanced (⭐⭐⭐): Implement a
spotNull<reified Any>function that filters by type and then casts. Hint: usefilterIsInstance<T>



