Generics

Type parameters, variance in both directions, star projection, reified types, and generic constraints.

Type parameters out / in Star projection reified Type erasure Constraints
๐Ÿ”ค

Type Parameters

A generic type parameter lets you write a function or class once and have it work correctly for any type, without giving up compile-time type checking the way a raw Any parameter would. <T> is a placeholder the caller fills in โ€” either explicitly, or inferred from the arguments โ€” and every use of T inside the body is checked as if it were a real type, because by the time the compiler is done, it is one.

Generic functions <T>
// T is inferred from the argument at each call site
fun <T> firstOrNull(list: List<T>): T? {
    return if (list.isEmpty()) null else list[0]
}

println(firstOrNull(listOf(1, 2, 3)))       // 1 โ€” T inferred as Int
println(firstOrNull(listOf("a", "b")))      // a โ€” T inferred as String
println(firstOrNull<Int>(emptyList())) // null โ€” T given explicitly, nothing to infer from
Generic classes class Box<T>
// The class itself is parameterized: every instance fixes T once
class Box<T>(var value: T) {
    fun <R> map(transform: (T) -> R): Box<R> = Box(transform(value))
}

val intBox = Box(42)           // Box
val stringBox = intBox.map { it.toString() }  // Box
println(stringBox.value)  // "42"
๐Ÿน
Coming from Go: Go only gained generics in 1.18, so the concept itself may still be newer ground even with a Go background. The core idea maps directly: Go's func First[T any](s []T) T and Kotlin's fun <T> firstOrNull(list: List<T>): T? solve the same problem the same way. The syntax difference is just bracket placement โ€” Kotlin's <T> comes before the function name, Go's after.
๐Ÿ“ค

Declaration-Site Variance: out / in

Without variance annotations, Box<Dog> and Box<Animal> are unrelated types even if Dog is a subtype of Animal โ€” generics are invariant by default, for a good reason covered below. Kotlin lets you opt into a safer form of subtyping directly on the type parameter's declaration: out marks a type parameter as covariant (only ever produced, read out, never accepted as input), and in marks it as contravariant (only ever consumed, accepted as input, never returned).

  out T  (covariant โ€” T only ever comes OUT)

    Producer<Dog>  is a subtype of  Producer<Animal>
    (a supply of Dogs can stand in for a supply of Animals)

  in T   (contravariant โ€” T only ever goes IN)

    Consumer<Animal>  is a subtype of  Consumer<Dog>
    (something that can handle any Animal can handle a Dog)

  Neither out nor in T  (invariant โ€” T is used both ways)

    Box<Dog>  and  Box<Animal>  are unrelated types
out: a type that's only produced out T
open class Animal
class Dog : Animal()

// out T: instances only ever return T, never accept it as a param
interface Producer<out T> {
    fun produce(): T
}

class DogProducer : Producer<Dog> {
    override fun produce() = Dog()
}

// Legal because of `out`: a Producer can stand in for Producer
val producer: Producer<Animal> = DogProducer()
val animal: Animal = producer.produce()
in: a type that's only consumed in T
// in T: instances only ever accept T as a parameter, never return it
interface Consumer<in T> {
    fun consume(item: T)
}

class AnimalConsumer : Consumer<Animal> {
    override fun consume(item: Animal) = println("consumed an animal")
}

// Legal because of `in`: something that consumes any Animal
// can be used wherever a Consumer is needed
val consumer: Consumer<Dog> = AnimalConsumer()
consumer.consume(Dog())
๐Ÿ’ก
The standard library's own types show the pattern clearly: List<out T> is covariant, because a read-only list only ever produces its elements โ€” this is exactly why List<String> can be passed where List<Any> is expected. MutableList<T> has no variance annotation, because add(item: T) both accepts and returns T, which is unsafe to treat as covariant (see why, next).
๐Ÿšง

Why Invariance Is the Safe Default

โš ๏ธ
Java arrays are covariant by default (Object[] arr = new String[3] compiles), and it's a well-known design mistake: nothing stops you from then writing arr[0] = 42, which compiles but throws ArrayStoreException at runtime. Kotlin generics are invariant unless you explicitly opt into out or in, precisely so the compiler โ€” not a runtime exception โ€” is what catches an unsafe substitution.
Why a mutable, invariant type can't safely be out The unsafe case
open class Animal
class Dog : Animal()
class Cat : Animal()

// If MutableList were allowed (it isn't โ€” this is illustrative):
// val dogs: MutableList = mutableListOf(Dog())
// val animals: MutableList = dogs      // pretend this compiled
// animals.add(Cat())                            // adding a Cat to a "List"!
// val d: Dog = dogs[1]                           // ClassCastException at runtime

// This is exactly why MutableList has no variance annotation:
// the compiler refuses the middle assignment outright
๐Ÿน
Coming from Go: Go's generics have no variance system at all โ€” []Dog and []Animal are simply unrelated types with no subtyping relationship, full stop. Kotlin's variance annotations let you selectively unlock the subtyping Go never offers, but only where it's provably safe.
๐Ÿ“

Use-Site Variance & Star Projection

Sometimes a type wasn't declared with out or in โ€” maybe you don't own it, or it genuinely needs to be invariant in general โ€” but at one specific call site, you only intend to read from it or only write to it. Use-site variance (also called type projection) lets you annotate the variance at the point of use instead of the declaration. Star projection, <*>, goes further: it means "some specific type, but I don't know or care which," useful when you need to accept any parameterization of a generic type without touching its contents.

out / in at the call site Type projection
// MutableList is invariant, but here we only ever read from source
// and only ever write to destination โ€” project the variance locally
fun <T> copy(source: MutableList<out T>, destination: MutableList<in T>) {
    for (item in source) {
        destination.add(item)
    }
}

val ints: MutableList<Int> = mutableListOf(1, 2, 3)
val numbers: MutableList<Number> = mutableListOf()
copy(ints, numbers)  // legal: source is read-only here, destination write-only
println(numbers)   // [1, 2, 3]
Star projection: any type, unknown <*>
// List<*> means "a List of some type, we don't know which"
// Reading elements gives Any? (safe: some unknown type IS an Any?)
// Writing is forbidden (unsafe: we don't know what type is expected)
fun printAll(list: List<*>) {
    for (item in list) {
        println(item)  // item: Any?
    }
}

printAll(listOf(1, 2, 3))
printAll(listOf("a", "b"))

// Common use: checking type/size without caring about the element type
fun isEmpty(list: List<*>) = list.isEmpty()
โ„น๏ธ
Star projection isn't the same as raw types in Java. List<*> is still fully type-checked โ€” you just don't know the specific element type at that point, so the compiler restricts you to operations that are safe regardless of what it turns out to be (reading as Any?, but never writing a specific value in).
๐Ÿ”ฌ

reified with inline

Normally you can't do x is T or T::class inside a generic function, because type erasure (next section) means T doesn't exist as a real type at runtime โ€” the JVM has already thrown it away. reified is Kotlin's escape hatch: it's only legal on an inline function's type parameter, because inlining pastes the function body โ€” including a fully-known T from the call site โ€” directly into the caller, so there's no erasure to fight.

Using the type at runtime reified T
// Without reified, `x is T` and `T::class` wouldn't compile here โ€”
// reified requires the function to be inline
inline fun <reified T> isInstance(x: Any?): Boolean = x is T

println(isInstance<String>("hi"))  // true
println(isInstance<Int>("hi"))     // false

// A very common real use: a typed JSON deserializer, avoiding
// having to pass Class or a TypeToken around manually
inline fun <reified T> parseJson(json: String): T {
    return Gson().fromJson(json, T::class.java)
}

val user: User = parseJson(jsonString)  // T inferred as User from the target type
๐Ÿ’ก
This is the trick behind Kotlin standard library functions like filterIsInstance<T>() and Android's getSystemService<T>() extensions โ€” anywhere a Java API needed you to pass a Class<T> object as a workaround for erasure, a reified inline wrapper lets Kotlin callers just write the type parameter and skip the extra argument entirely.
๐Ÿ‘ป

Type Erasure

Kotlin generics, like Java's, exist only at compile time. Once compiled to JVM bytecode, List<String> and List<Int> are both just List โ€” the type argument is erased. This is a JVM platform constraint Kotlin inherited, not a design choice Kotlin made; it's why reified needs the inline workaround above, and why certain generic type checks are restricted or produce a compiler warning instead of an error.

What erasure forbids Erasure
// This does not compile: T isn't available at runtime to check against
fun <T> isTypeOf(x: Any?): Boolean {
    // return x is T   // ERROR: cannot check for erased type T
    return false
}

// Checking the outer type works fine; the type argument is unchecked
val list: Any = listOf(1, 2, 3)
if (list is List<*>) {          // fine: star projection acknowledges the erasure
    println("it's a list")
}
// if (list is List) { }  // warning: "unchecked cast", can't verify element type
โ„น๏ธ
Erasure is why reified only works through inline: inlining is the one mechanism that gets the actual type back into scope at the call site, before erasure has a chance to throw it away during compilation to bytecode.
๐Ÿ“

Generic Constraints

By default a type parameter is bounded only by Any? โ€” it could be anything, including null. An upper bound narrows that, restricting T to a specific type or its subtypes, which is what lets you call that type's members directly on values of type T inside the function body. A where clause lets you require more than one bound at once.

Upper bounds T : Bound
// T : Comparable restricts T to comparable types,
// which is what makes compareTo callable below
fun <T : Comparable<T>> max(a: T, b: T): T =
    if (a.compareTo(b) >= 0) a else b

println(max(3, 7))         // 7: Int implements Comparable
println(max("apple", "banana")) // "banana": String implements Comparable
// max(User("Ada"), User("Grace"))  // ERROR unless User implements Comparable

// Without a bound, T defaults to Any?, and .compareTo wouldn't exist:
// fun  max(a: T, b: T) = if (a.compareTo(b) >= 0) a else b  // ERROR
Multiple bounds with where where
interface Named { val name: String }

// T must satisfy BOTH constraints: Comparable AND Named
fun <T> describeLargest(items: List<T>): String
        where T : Comparable<T>, T : Named {
    val largest = items.maxOrNull() ?: return "empty"
    return "Largest: ${largest.name}"   // .name available thanks to the Named bound
}
๐Ÿน
Coming from Go: Go expresses the same idea with a constraint interface: func Max[T constraints.Ordered](a, b T) T. Kotlin's T : Comparable<T> upper bound and a where clause for multiple constraints are the direct equivalent of Go's type-parameter constraint interfaces โ€” same purpose, different syntax.
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Generic functionfun <T> f(x: T): TT inferred from arguments, or given explicitly
Generic classclass Box<T>(val v: T)Each instance fixes T once
Covariant (declaration-site)interface P<out T>T only produced; Producer<Dog> : Producer<Animal>
Contravariant (declaration-site)interface C<in T>T only consumed; Consumer<Animal> : Consumer<Dog>
Invariant (default)class Box<T>No subtyping between Box<Dog> and Box<Animal>
Use-site projectionfun f(x: MutableList<out T>)Local read-only view of an invariant type
Star projectionList<*>Unknown type argument; read as Any?, can't write
Reified type parameterinline fun <reified T> f()Only legal on inline functions; enables `is T`, `T::class`
Type erasureList<String> โ†’ List at runtimeType arguments don't exist in compiled bytecode
Upper bound<T : Comparable<T>>Restricts T, enables calling Bound's members on it
Multiple bounds<T> ... where T : A, T : BT must satisfy every listed constraint