Variance: in and out

Variance answers a question that is easy to overlook: if Dog is a subtype of Animal, is Box<Dog> a subtype of Box<Any>? For an ordinary generic class the answer is no — Kotlin generics are invariant by default, so Box<Dog> and Box<Animal> are unrelated types even though Dog and Animal are related. The out and in keywords let you tell the compiler exactly when that relationship should hold, so generic APIs can stay both safe and flexible. This lesson explains what variance means, how the compiler checks it, and how to use declaration-site and use-site variance correctly.

Overview / How it works

By default, a generic type parameter in Kotlin is invariant. Given class Box<T>(var value: T), the types Box<String> and Box<Any> share no subtyping relationship, even though every String is an Any. This looks strict until you see why it exists. If the compiler allowed a Box<String> to be used wherever a Box<Any> is expected, a function holding the Box<Any> reference could legally write box.value = 42 — an Int is a valid Any — but the underlying object is really a Box<String>. Any code that later reads value back out as a String would crash at runtime. Invariance closes that hole by refusing to compile the unsafe assignment in the first place.

Covariance, marked with out, tells the compiler that a type only ever produces values of T — it hands them out through return types or val getters, and never accepts a T as input. class Producer<out T>(val value: T) only ever reads value; nothing lets you overwrite it with a possibly wrong-typed value. Because of that guarantee, the compiler allows Producer<String> to be treated as a Producer<Any>: you can only take Anys out of it, and every String qualifies. This is exactly how the standard library’s read-only List<out E> works — List<String> is a subtype of List<Any> — while MutableList<E> stays invariant, because its add(element: E) accepts a T as input and reopens the heap-pollution risk described above.

Contravariance, marked with in, is the mirror image: the type only ever consumes values of T, accepting them as parameters and never handing one back out. The standard library’s Comparable<in T> is the classic example — something that knows how to compare itself against any Any can certainly compare itself against a String, so Comparable<Any> can stand in wherever Comparable<String> is required. Notice the direction flips relative to covariance: with in, the more general type becomes the subtype of the more specific requirement.

The compiler enforces this by scanning every public member for how it uses T. Positions that hand a T back to the caller — return types, and a val‘s generated getter — are out positions. Positions that receive a T from the caller — function parameters, and the setter a var implicitly generates — are in positions. A class declared out T may not use T in any in position; a class declared in T may not use it in any out position. This check is purely compile-time and adds no runtime cost — variance is a type-system feature, not a runtime mechanism, and under JVM type erasure a Box<String> and a Box<Any> are literally the same class at runtime.

Kotlin supports variance in two places. Declaration-site variance is what you’ve seen so far: write out or in once on the class, and every use respects it. Java has no equivalent — Java programmers repeat wildcards (? extends T, ? super T) at every call site. But some types, like the built-in Array<T>, are invariant by design and can’t be changed. For those, Kotlin offers use-site variance: apply out or in to a single parameter’s type where you need it, instead of to the whole class.

Sometimes you don’t care what the type argument is at all. For that, Kotlin has the star projection <*>. List<*> means "a list of some type I’m not tracking", roughly List<out Any?>: you can still safely read elements out as Any?, but the compiler won’t let you add anything, since it has no idea what type would be safe.

fun printSize(list: List<*>) {
    println("Size: ${list.size}")
}

fun main() {
    val numbers: List<Int> = listOf(1, 2, 3, 4)
    printSize(numbers)
}

Output:

Size: 4

list.size doesn’t need to know the element type, so it works fine through a star projection; only element-accepting operations like add would be rejected.

Syntax

Form Meaning Where T may appear
class Name<out T> Declaration-site covariance Only out positions: return types, val getters
class Name<in T> Declaration-site contravariance Only in positions: function parameters
class Name<T> Invariant (default) Anywhere, but no subtyping between different arguments
Type<out T> at a call site Use-site covariance (projection) Treat this one usage as read-only
Type<in T> at a call site Use-site contravariance (projection) Treat this one usage as write-only
Type<*> Star projection Unknown argument; safe to read as Any?, unsafe to write

A bare reference showing the shapes side by side:

class Producer<out T> {
    // T may appear only in "out" positions: return types, val getters
}

class Consumer<in T> {
    // T may appear only in "in" positions: function parameters
}

class Invariant<T> {
    // T may appear anywhere, but Invariant<A> and Invariant<B> are unrelated types
}

fun readOnlyView(numbers: MutableList<out Number>) {
    // use-site variance: numbers can only be read here, not written to
}

fun writeOnlyView(ints: MutableList<in Int>) {
    // use-site variance: ints only accepts Int (or subtypes) here
}

Examples

Example 1: Declaration-site covariance with out

class Box<out T>(val value: T)

fun printBox(box: Box<Any>) {
    println(box.value)
}

fun main() {
    val stringBox: Box<String> = Box("Hello, Kotlin")
    printBox(stringBox)
}

Output:

Hello, Kotlin

Box only exposes T through the val getter, an out position, so declaring it out T is legal. Because String is a subtype of Any, Box<String> becomes a subtype of Box<Any>, and passing stringBox to a function expecting Box<Any> compiles without a cast.

Example 2: Declaration-site contravariance with in

class Printer<in T> {
    fun printItem(item: T) {
        println("Printing: $item")
    }
}

fun main() {
    val anyPrinter: Printer<Any> = Printer()
    val stringPrinter: Printer<String> = anyPrinter
    stringPrinter.printItem("Hello")
}

Output:

Printing: Hello

Printer only ever accepts a T as a parameter, an in position, so in T is legal. A Printer<Any> can print anything, so it can safely be treated as a Printer<String> — the assignment direction is reversed compared to the covariant example.

Example 3: Use-site variance with Array

fun copyAll(from: Array<out Any>, to: Array<Any>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}

fun main() {
    val ints: Array<Int> = arrayOf(1, 2, 3)
    val anyArray: Array<Any> = arrayOf("a", "b", "c")
    copyAll(ints, anyArray)
    println(anyArray.joinToString())
}

Output:

1, 2, 3

Array<T> is invariant, so Array<Int> normally could not be passed where Array<Any> is expected. Projecting the parameter as Array<out Any> tells the compiler "inside this function, only read from from, never write to it" — which makes accepting an Array<Int> safe for that one parameter, without changing Array‘s general invariance.

How it works step by step

Walking through Example 1: (1) the compiler checks class Box<out T>(val value: T) and confirms T only appears as a val getter, an allowed out position. (2) Box("Hello, Kotlin") infers T = String from the constructor argument. (3) stringBox is typed Box<String>. (4) at the call printBox(stringBox), the compiler needs Box<String> to be assignable to the parameter type Box<Any>; because Box is declared out T and String <: Any, the subtyping check succeeds. (5) inside printBox, box.value is read with static type Any, and println calls its toString(), printing the original string. (6) at runtime, due to JVM type erasure, there is only one Box class with an Object-typed field — variance exists purely to stop the compiler from accepting an unsafe write, not to change what’s stored in memory. The contravariant case in Example 2 works the same way but with the subtyping direction reversed: the compiler allows Printer<Any> to satisfy a Printer<String> variable precisely because every in-position use of T inside Printer can safely accept a String.

Common Mistakes

Mistake 1: Putting a var (or setter) in an out class

class Box<out T>(var value: T) {
    fun setValue(newValue: T) {
        value = newValue
    }
}

This fails to compile: "Type parameter T is declared as ‘out’ but occurs in ‘in’ position". A var generates a public setter that accepts T as a parameter, and setValue does too — both are in positions, which an out type parameter is forbidden from using anywhere in the public API. Fix it by keeping the property read-only:

class Box<out T>(val value: T)

fun main() {
    val box: Box<String> = Box("Immutable and safe")
    println(box.value)
}

Output:

Immutable and safe

Mistake 2: Returning T from an in class

class Consumer<in T> {
    fun produce(): T {
        return TODO()
    }
}

This fails with "Type parameter T is declared as ‘in’ but occurs in ‘out’ position", because the return type of produce hands a T back to the caller. An in type parameter may only appear where the caller supplies a value, never where the class supplies one. The fix is to only ever accept T, not return it:

class Consumer<in T> {
    fun consume(item: T) {
        println("Consumed: $item")
    }
}

fun main() {
    val consumer: Consumer<Number> = Consumer()
    consumer.consume(42)
}

Output:

Consumed: 42

Mistake 3: Assuming MutableList behaves like a covariant List

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

fun main() {
    val strings: MutableList<String> = mutableListOf("a", "b")
    addAnything(strings)
}

This fails with a type mismatch: MutableList<String> is not a MutableList<Any>, because MutableList<E> is invariant — its add uses E in an in position, so allowing this call would let an Int land inside a list the caller believes only holds Strings. If you don’t actually need to mutate the collection, use the covariant read-only List instead:

fun printAll(list: List<Any>) {
    for (item in list) {
        println(item)
    }
}

fun main() {
    val strings: List<String> = listOf("a", "b")
    printAll(strings)
}

Output:

a
b

Best Practices

  • Declare a type parameter out only when it never appears as a function parameter or a var‘s implicit setter — the compiler will tell you immediately if you got it wrong.
  • Prefer the read-only List, Set, and Map interfaces over their mutable counterparts when a function only needs to read, since they’re covariant and compose more freely.
  • Reach for use-site variance (Array<out T>, MutableList<in T>) when working with an invariant type you don’t control, instead of writing overloads for every combination of type arguments.
  • Use star projection <*> when you genuinely don’t need the type argument, such as checking size or type membership, rather than reaching for an unchecked cast.
  • Remember variance is compile-time only; it costs nothing at runtime and doesn’t change how the JVM represents your objects.
  • Don’t fight a variance error with @UnsafeVariance or an unchecked cast unless you fully understand and can guarantee the safety the compiler would otherwise enforce.

Practice Exercises

  • Try writing a generic Stack<out T> class with both a push(item: T) and a pop(): T method. What compiler error do you get, and which method causes it? Explain why a single-type stack fundamentally cannot be purely covariant.
  • Given open class Animal and class Dog : Animal(), write a covariant class Cage<out T>(val occupant: T), then write a function fun describe(cage: Cage<Animal>) and call it with a Cage<Dog>. Confirm it compiles without a cast.
  • Write a function fun countIf(list: List<*>, matches: (Any?) -> Boolean): Int that counts how many elements of an unknown-typed list satisfy a predicate, using only operations available through a star projection.

Summary

  • Generic type parameters are invariant by default: Box<String> and Box<Any> are unrelated types.
  • out T (covariance) means the type only produces T; Producer<String> becomes a subtype of Producer<Any>.
  • in T (contravariance) means the type only consumes T; Consumer<Any> becomes a subtype of Consumer<String>.
  • The compiler enforces variance by checking every use of T is in an allowed position — out positions are return types and val getters; in positions are function parameters and var setters.
  • Declaration-site variance (out/in on the class) replaces Java’s repetitive wildcard syntax at every call site.
  • Use-site variance (Array<out Any>) lets you project a single usage of an otherwise-invariant type like Array.
  • Star projection <*> is for when the type argument is unknown or irrelevant; it allows safe reads but blocks writes.
  • Variance is entirely a compile-time type-system feature with zero runtime cost.