Generics

Kotlin

2026-08-21 09:00

Where we are

Used everywhere, explained nowhere

List<String> · (Int) -> Int · Map<String, Person>

This unit explains them.

Three topics, very different difficulty

Declaring — mostly syntax you know from Java.

Erasure — a JVM constraint, with a clever workaround.

Variance — a genuine question, and the hard part.

The question variance asks

If String is a subtype of Any,

is List<String> a subtype of List<Any>?

The answer is sometimes.

What you will be able to do

  1. Declare generic functions, classes and extensions.
  2. Constrain a type parameter.
  3. Explain type erasure.
  4. Use reified type parameters.
  5. Explain variance and why the obvious answer is unsafe.
  6. Declare covariance and contravariance.
  7. Distinguish declaration-site from use-site variance.
  8. Use star projections.

Declaring generics

A type parameter

Usually inferred

println(letters.slice<Char>(0..2))    // explicit
println(letters.slice(10..13))        // inferred from the receiver

Generic classes

class StringList : List<String> {     // concrete argument
    override fun get(index: Int): String = ...
}

class ArrayList<T> : List<T> {        // stays generic
    override fun get(index: Int): T = ...
}

Upper bounds

fun <T : Number> List<T>.sum(): T

fun <T : Comparable<T>> max(first: T, second: T): T =
    if (first > second) first else second

Without the bound, > would not compile.

Several bounds

fun <T> ensureTrailingPeriod(seq: T)
        where T : CharSequence, T : Appendable {
    if (!seq.endsWith('.')) seq.append('.')
}

The nullability default

fun <T> printHashCode(t: T) {
    println(t?.hashCode())       // the ?. is required
}

An unbounded T has upper bound Any?. So T is nullable.

Ask for the guarantee

fun <T : Any> printHashCode(t: T) {
    println(t.hashCode())        // fine
}

Unit 6’s discipline, applied to type parameters.

The permissive case is the default. Always.

Erasure

What the JVM sees

Discarded at compile time

Generics were added to Java after the fact.

For compatibility, the type arguments are thrown away.

Kotlin inherits this, because it targets the JVM.

What becomes impossible

if (value is List<String>) { }      // ERROR
  • the check cannot be performed
  • you cannot create an instance of a type parameter
  • overloads differing only in type argument clash

Star projections

if (value is List<*>) { }           // this compiles

Elements read out are Any?. You cannot write.

Not the same as List<Any?>

List<Any?> — the element type is Any?.

List<*> — the element type is something specific we do not know.

Confusing them produces surprising errors, usually a refused write.

When erasure is fine

Almost always.

The compiler already checked; there is nothing left to verify.

It bites in exactly two places: runtime type checks and reflection.

Reified type parameters

The trick

inline fun <reified T> isA(value: Any) = value is T

println(isA<String>("abc"))    // true
println(isA<String>(123))      // false

Nothing was un-erased

Inlining substitutes the body at the call site.

And the type argument is known at each call site.

isA<String>(x) becomes, literally, x is String.

Which is why the restriction exists

Only inline functions can have reified parameters.

A non-inlined function has one body shared by all callers.

No single type to substitute.

Second thing inlining paid for

Unit 8: non-local returns.

Unit 9: reified type parameters.

What it enables

items.filterIsInstance<String>()
val person = parse<Person>(json)       // Kotlin
Person p = parse(json, Person.class);  // Java

The reflection unit is full of the second shape.

A caution

Reification requires inlining.

Keep reified functions small.

Extract the bulk into a non-inline helper.

Variance: the problem

Subtyping

The question

A String may be used where an Any is expected.

May a List<String> be used where a List<Any> is expected?

Why “obviously yes” is wrong

fun addAnswer(list: MutableList<Any>) {
    list.add(42)
}

val strings = mutableListOf("abc", "bac")
addAnswer(strings)                    // if this were allowed...
println(strings.maxBy { it.length })  // ...ClassCastException

So it depends on what the type does

Only produces → safe. Everything out is a String, and a String is an Any.

Also consumes → not safe.

Unit 6 was setting this up

List only produces → can be covariant.

MutableList consumes → cannot.

That earlier split exists to make this one possible.

The terms

Covariant — subtyping preserved.

Contravariant — subtyping reversed.

Invariant — neither. Java’s default, which is why wildcards exist.

Also worth seeing

out and in

Both at once

out — covariance

interface Producer<out T> {
    fun produce(): T
}

Only produces. Subtyping preserved.

Position rule: may appear only in return types.

in — contravariance

interface Comparator<in T> {
    fun compare(e1: T, e2: T): Int
}

Only consumes. Subtyping reversed.

A Comparator<Any> can compare two strings.

The mnemonic

out for producers, in for consumers.

And the position rules follow from it:

a producer’s type is where values come out; a consumer’s, where they go in.

Function types show both

Which makes sense

A function accepting more kinds of input

and returning a more specific result

can stand in for one that accepts fewer and returns something vaguer.

Declaration-site variance

void copyAll(Collection<? super T> to, Collection<? extends T> from)
fun <T> copyAll(to: MutableCollection<in T>, from: Collection<T>)

Java: at every use. Kotlin: once, on the class.

Type projections

fun <T> copyData(source: MutableList<out T>,      // only produces
                 destination: MutableList<T>) {
    for (item in source) destination.add(item)
}

source.add(...) will not compile. The projection is enforced.

The practical guidance

Most of the time you use variance without declaring any.

List is already out T. Comparator is already in T.

And when you write your own

Does this type produce its parameter, consume it, or both?

Produce → out. Consume → in.

Both → invariant, and project at the use sites that need one direction.

Summary

The six things to carry away

  • An unbounded T is nullable — its upper bound is Any?.
  • Erasure bites in exactly two places: runtime type checks and reflection.
  • List<*> and List<Any?> are different claims.
  • reified works because the type argument is known at each call site.
  • Variance is a real question, and the mutable-list argument is the answer.
  • out for producers, in for consumers — declared once, unlike Java’s wildcards.

Where next

Annotations and Reflection — inspecting and acting on code at runtime.

Erasure returns immediately, since reflection is where it is most missed.

And reified parameters return as what makes reflection APIs pleasant to call.