If Kotlin’s nullability system is about local correctness, then generics are about compositional correctness.
They define how types relate across abstraction boundaries, where misuse quickly turns into either unsafe APIs or unusable ones.
This article dissects Kotlin generics from a language and compiler perspective, focusing on variance, type projection, and the JVM constraints that shape Kotlin’s design.
Core Rule: Why Generics Are Invariant by Default
Rule 1.1 — Generic type parameters are invariant unless explicitly annotated
Formally:
If A <: B
Then G<A> </: G<B>
Example:
val strings: List<String> = listOf("a", "b")
val anys: List<Any> = strings // ❌ compilation error
Why this rule exists
If this assignment were allowed, the following would become legal:
anys.add(42) // inserts Int
val s: String = strings[0] // runtime type corruption
Invariant is the only safe default when a generic type can both:
- produce values of
T - consume values of
T
Kotlin requires you to prove safety before relaxing this rule.
Declaration-site Variance (out / in)
Rule 2.1 — out T means the type may only produce T
Formal constraint:
If a type parameter is marked
out T, then:
Tmay appear only in covariant positions (return types)Tmust not appear in contravariant positions (parameters)
Valid example
class Producer<out T>(
private val value: T
) {
fun get(): T = value
}
Usage:
val p: Producer<String> = Producer("hello")
val a: Producer<Any> = p // allowed
Why allowed:
Producer<String>can safely act asProducer<Any>- It never accepts an
Any, only returns one
Invalid example
class InvalidProducer<out T> {
fun set(value: T) {} // not allowed
}
Compiler reasoning:
- If this were allowed, you could pass an
Anyinto aProducer<String> - This violates covariance guarantees
Rule 2.2 — in T means the type may only consume T
Formal constraint:
If a type parameter is marked
in T, then:
Tmay appear only in parameter positionsTmust not appear in return positions
Valid example
class Consumer<in T> {
fun accept(value: T) {}
}
Usage:
val c: Consumer<Any> = Consumer()
val s: Consumer<String> = c // allowed
Why allowed:
- A consumer of
Anycan safely consumeString
Invalid example
class InvalidConsumer<in T> {
fun produce(): T // not allowed
}
Why rejected:
- The caller expects a
String - The implementation may return an
Any - Type safety breaks
Mixed Usage Is Forbidden by Construction
Rule 3.1 — A type parameter used in both input and output positions must be invariant
Example:
class Box<T>(var value: T)
Why T cannot be out or in:
valueis both read and written- No safe variance direction exists
Attempting:
class Box<out T>(var value: T) // not allowed
Compiler error is structural, not stylistic.
Use-site Variance (Type Projection)
Rule 4.1 — Use-site variance restricts capabilities, not types
fun readOnly(list: List<out Number>) {
val n: Number = list[0] // allowed
list.add(1) // not allowed
}
Meaning:
- The actual list may be
List<Int>,List<Double>, etc. - Kotlin only allows operations safe for all possibilities
Rule 4.2 — out projection removes write capability
val ints: MutableList<Int> = mutableListOf(1, 2)
val numbers: MutableList<out Number> = ints
numbers.add(3) // not allowed
Why:
- The actual list might be
MutableList<Double> - Writing would violate type safety
Star Projection (*) — Formal Semantics
Rule 5.1 — Star projection means “unknown type with maximal safety”
For covariant types:
List<*> ≡ List<out Any?>
Example:
fun f(list: List<*>) {
val x: Any? = list[0] // allowed
}
Rule 5.2 — For invariant mutable types
MutableList<*> ≡ MutableList<out Any?>
Effect:
fun f(list: MutableList<*>) {
list.add(1) // not allowed
list.add(null) // allowed (only safe value)
}
Star projection is existential typing, not shorthand.
Generic Constraints — What They Actually Enforce
Rule 6.1 — Upper bounds are compile-time only
fun <T : CharSequence> print(t: T) {
println(t.length)
}
Guarantee:
Thaslength- Nothing more
At runtime:
Tis erased
Rule 6.2 — Multiple bounds are intersection types
fun <T> f(t: T)
where T : Closeable,
T : Runnable
Meaning:
Tmust satisfy all constraints- Kotlin does not reify this intersection at runtime
Reified Type Parameters — Exact Boundaries
Rule 7.1 — Reification only works for the top-level type
inline fun <reified T> isType(x: Any): Boolean =
x is T
Valid:
isType<String>("hello") // true
Invalid:
x is List<T> // not allowed
Why:
Tis knownList<T>still suffers from erasure
Reification ≠ runtime generics.
Mental Model
Think in capabilities, not hierarchy:
| Annotation | Can Read | Can Write |
|---|---|---|
T | allowed | allowed |
out T | allowed | not allowed |
in T | not allowed | allowed |
* | Any? | null |
If you ask:
“Why does Kotlin forbid this?”
The answer is almost always:
“Because at least one legal instantiation would break type safety.”
Practical Use Cases of Generics in Kotlin
Why Generics Exist: What Problem Are You Solving?
Before discussing how to use generics, we need to clarify why they should exist at all.
Core rule:
Use generics to preserve type information across abstraction boundaries.
If a type parameter does not preserve or constrain information meaningfully, it is probably unnecessary.
Use Case 1 — Type-preserving Transformations
Problem
You want to transform values without losing their concrete type.
Without generics (bad)
fun wrap(value: Any): Wrapper {
return Wrapper(value)
}
Problems:
- Loses type information
- Requires casts downstream
- Pushes unsafety to callers
With generics (correct)
class Wrapper<T>(val value: T)
fun <T> wrap(value: T): Wrapper<T> =
Wrapper(value)
Now:
- Type is preserved end-to-end
- No casts
- Compiler enforces correctness
Rule:
If the output type depends directly on the input type, you need generics.
Use Case 2 — Enforcing Relationships Between Multiple Values
Problem
You want to ensure two or more values are of the same type.
Example
fun <T> pair(a: T, b: T): Pair<T, T> =
Pair(a, b)
This prevents:
pair(1, "hello") // not allowed
Without generics, this relationship would be informal or runtime-checked.
Rule:
Use generics when multiple parameters must share a type relationship.
Use Case 3 — Capability-based APIs (Read vs Write)
Generics + variance are ideal for capability restriction.
Example: Read-only access
fun sum(values: List<out Number>): Double =
values.sumOf { it.toDouble() }
Why this is good:
- Accepts
List<Int>,List<Double>, etc. - Communicates intent: read-only
- Prevents mutation by construction
Rule:
Use variance to encode what the caller is allowed to do.
Use Case 4 — Generic Algorithms, Not Generic Data
Generics shine more in algorithms than in data holders.
Example
fun <T : Comparable<T>> max(a: T, b: T): T =
if (a >= b) a else b
Here:
- Algorithm is generic
- Constraint defines required behavior
- No runtime cost
Anti-pattern
class Holder<T>
…when T is never used meaningfully.
Rule:
Generic behavior is more valuable than generic storage.
Use Case 5 — API Flexibility Without Type Loss
Example: Producer API
interface Source<out T> {
fun next(): T
}
This allows:
val stringSource: Source<String>
val anySource: Source<Any> = stringSource
Without generics, you would be forced into:
Any- casts
- duplicated interfaces
Rule:
Generics allow APIs to be flexible without sacrificing type safety.
When NOT to Use Generics
When the type is not semantically relevant
class Cache<T>
If:
Tis never exposedTdoes not affect behaviorTis only stored and retrieved blindly
Then generics add ceremonial complexity.
When the type domain is finite
Bad:
class Result<T>
Better:
sealed class Result {
data class Success(val value: Int) : Result()
data class Error(val error: Throwable) : Result()
}
If the set of types is closed and known, sealed types are clearer.
Rule:
Use generics for open type spaces, sealed types for closed ones.
Generics vs Overloading vs Sealed Types
| Requirement | Best Tool |
|---|---|
| Same logic, many types | Generics |
| Few known variants | Sealed classes |
| Different behavior per type | Polymorphism |
| Runtime type switching | when + sealed |
Generics are not a replacement for modeling.
API Design Rule of Thumb
Before introducing a type parameter T, ask:
- Does
Tpreserve information across layers? - Does
Tenforce a meaningful constraint? - Would removing
Tforce unsafe casts? - Will
Tleak into public ABI?
If the answer is “no” to most of these → don’t use generics.
Closing
Kotlin generics are intentionally explicit and restrictive.
They force you to encode data flow direction into the type system.
For senior developers:
- Variance is not syntax
- It is API semantics
- Once published, it is extremely hard to change safely
