Generics in Kotlin: Variance, Constraints, and Type Projection

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.

Table of Contents

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:

  • T may appear only in covariant positions (return types)
  • T must 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 as Producer<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 Any into a Producer<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:

  • T may appear only in parameter positions
  • T must 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 Any can safely consume String

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:

  • value is 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:

  • T has length
  • Nothing more

At runtime:

  • T is erased

Rule 6.2 — Multiple bounds are intersection types

fun <T> f(t: T)
where T : Closeable,
      T : Runnable

Meaning:

  • T must 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:

  • T is known
  • List<T> still suffers from erasure

Reification ≠ runtime generics.


Mental Model

Think in capabilities, not hierarchy:

AnnotationCan ReadCan Write
Tallowedallowed
out Tallowednot allowed
in Tnot allowedallowed
*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:

  • T is never exposed
  • T does not affect behavior
  • T is 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

RequirementBest Tool
Same logic, many typesGenerics
Few known variantsSealed classes
Different behavior per typePolymorphism
Runtime type switchingwhen + sealed

Generics are not a replacement for modeling.


API Design Rule of Thumb

Before introducing a type parameter T, ask:

  1. Does T preserve information across layers?
  2. Does T enforce a meaningful constraint?
  3. Would removing T force unsafe casts?
  4. Will T leak 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

Leave a Reply

Your email address will not be published. Required fields are marked *