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
outonly when it never appears as a function parameter or avar‘s implicit setter — the compiler will tell you immediately if you got it wrong. - Prefer the read-only
List,Set, andMapinterfaces 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
@UnsafeVarianceor 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 apush(item: T)and apop(): Tmethod. 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 Animalandclass Dog : Animal(), write a covariantclass Cage<out T>(val occupant: T), then write a functionfun describe(cage: Cage<Animal>)and call it with aCage<Dog>. Confirm it compiles without a cast. - Write a function
fun countIf(list: List<*>, matches: (Any?) -> Boolean): Intthat 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>andBox<Any>are unrelated types. out T(covariance) means the type only producesT;Producer<String>becomes a subtype ofProducer<Any>.in T(contravariance) means the type only consumesT;Consumer<Any>becomes a subtype ofConsumer<String>.- The compiler enforces variance by checking every use of
Tis in an allowed position — out positions are return types andvalgetters; in positions are function parameters andvarsetters. - Declaration-site variance (
out/inon 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 likeArray. - 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.
