Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson, you modeled a search screen with a sealed SearchUiState: each value describes one valid search outcome. Now consider an API that passes those values between components. If a component handles any SearchUiState, should it accept a source that provides only Results? What about a destination that accepts every state?
This lesson answers those questions with variance. You’ll use out and in at a generic type’s declaration when its role is fixed, then use them at a particular call site when the underlying type must support both reading and writing. The distinction matters in Android APIs that pass state, data, and callbacks across component boundaries.
Why a subtype does not automatically make a generic subtype
From the previous lesson, SearchUiState.Results is a subtype of SearchUiState. A result can therefore be used wherever an individual search state is expected. That rule does not automatically extend to containers:
class Slot<T>(var value: T)
val result = SearchUiState.Results(
listOf(Article(ArticleId("a17"), "Modeling State"))
)
val resultSlot: Slot<SearchUiState.Results> = Slot(result)
// Does not compile:
// val stateSlot: Slot<SearchUiState> = resultSlot
Suppose the commented assignment were allowed. Code holding stateSlot could write SearchUiState.Loading into it. But resultSlot promises that its value is a Results. Both references would point to the same slot, and that promise would be broken.
Kotlin therefore treats Slot<T> as invariant by default: even when Results is a subtype of SearchUiState, neither Slot<Results> nor Slot<SearchUiState> is automatically a subtype of the other. The obstacle is not generics themselves. It is that Slot both produces a T when you read value and consumes a T when you assign it.
Watch Dave Leeds’s Variance... without Generics! for the underlying substitution rule. The vending-machine example separates what an API returns from what it accepts before we apply that rule to generic types.
In “Variance... without Generics!” from Dave Leeds, watch how return and input types place different demands on a subtype.
Watch return values for why an implementation may return something more specific than its contract promises. Then watch input values for why an implementation that accepts only a narrower input cannot replace one that promises to accept every input of the original type. Focus on those safety arguments rather than the vending-machine details.
Here is the rule to carry into the Kotlin code: a more specific producer is safe to use as a more general producer; a more general consumer is safe to use as a more specific consumer.
Declare variance when an API has one role
Imagine two small boundaries around the search screen. One supplies a state; the other receives one:
interface StateSource<out T> {
fun current(): T
}
interface StateSink<in T> {
fun accept(value: T)
}
StateSource only returns T, so its declaration says out T. It is covariant: a source of Results can stand in for a source of SearchUiState, because every result is a search state.
StateSink only accepts T, so its declaration says in T. It is contravariant: a sink capable of accepting any SearchUiState can stand in for a sink that will be given only Results.
val resultsSource: StateSource<SearchUiState.Results> =
object : StateSource<SearchUiState.Results> {
override fun current(): SearchUiState.Results = result
}
val allStateSink: StateSink<SearchUiState> =
object : StateSink<SearchUiState> {
override fun accept(value: SearchUiState) {
println(value)
}
}
val screenSource: StateSource<SearchUiState> = resultsSource
val resultsSink: StateSink<SearchUiState.Results> = allStateSink
resultsSink.accept(resultsSource.current())
Notice the opposite directions of the two assignments. screenSource can return a SearchUiState; through that reference, you must not assume every returned state is a Results, even though this particular source happens to supply one. resultsSink accepts a Results; through that reference, you must not assume you can send it Loading, even though this particular sink happens to accept that too. The reference’s type states the guarantees available to its caller.
The compiler also checks the interface declarations. Adding fun send(value: T) to StateSource<out T> would conflict with its producer promise. Adding fun current(): T to StateSink<in T> would conflict with its consumer promise. If an abstraction genuinely needs both operations, it cannot simply be declared out or in.
Read the official Kotlin documentation’s “Declaration-site variance” subsection to see these rules stated in terms of type positions. Its Source and Comparable examples correspond to our source and sink.
Generics: in, out, where | Kotlin Documentation
Read Kotlin’s “Generics: in, out, where” documentation for the compiler rules behind the producer and consumer assignments.
In “Declaration-site variance,” start at the paragraph beginning “Let’s suppose that there is a generic interface” and read the variance explanation through the introduction of in. Focus on why out restricts a type parameter to output positions and in restricts it to input positions. The displayed examples use Source and Comparable; map their roles to StateSource and StateSink rather than memorizing their names.
This is declaration-site variance because the decision is attached to the interface’s type parameter and applies wherever that interface is used. Kotlin’s read-only List<out E> is another familiar producer-oriented API. By contrast, a mutable collection needs to accept as well as provide elements, so you cannot safely treat a MutableList<Results> as a MutableList<SearchUiState> and start inserting other states.
Project an invariant type for one particular use
Sometimes both operations are essential to a type. An Array<T> lets you read and replace its elements, so its type parameter is invariant. Yet a particular function might only read one array and write another. Rather than changing Array, specify the narrower permissions at that function boundary:
fun <T> copyItems(
from: Array<out T>,
to: Array<in T>
) {
require(from.size <= to.size)
for (index in from.indices) {
to[index] = from[index]
}
}
val resultArray: Array<SearchUiState.Results> = arrayOf(result)
val stateArray: Array<SearchUiState> =
arrayOf(SearchUiState.Idle)
copyItems(resultArray, stateArray)
from: Array<out T> is an out-projection: this function may read values as T, but cannot safely put a T into from. The actual array might hold a more specific type than T. to: Array<in T> is an in-projection: the function may put T values into to, but cannot safely assume that a value it reads back is a T. The actual array might hold a more general type.
That is why the call accepts an array of Results as the source and an array of SearchUiState as the destination. For this call, results can be read as states, and a destination that accepts every state can accept results. The original arrays remain mutable through appropriately typed references; the projections restrict what this function is allowed to do with them.
Dave Leeds’s Type Projections... and why they work! makes those restrictions visible, including why an out-projected input becomes effectively unusable and why an in-projected return value loses its specific type.
Type Projections... and why they work!
Watch “Type Projections... and why they work!” from Dave Leeds to connect the restricted view of an invariant object with Kotlin’s out and in syntax.
Start with the mixed-role problem. Watch out projections and in projections for the operations each view gives up. Finish with the array example, which uses the same kind of mutable type as copyItems.
The diagrams below show the restriction from each side. Their Group<T> is an invariant type that can both insert and fetch values, much like an Array<T> can write and read them.


Nothing has no ordinary value you can supply, so treating an out-projected input as Nothing prevents an unsafe insertion. Any? is broad enough to describe an unknown returned value, but it does not let you claim that value is a Dog. These are compile-time restrictions on the projected reference, not changes to the object behind it.
For the precise array example and the name type projection, read the corresponding subsection of the Kotlin documentation. Its key distinction is that the class remains invariant while a particular use of it is restricted.
Generics: in, out, where | Kotlin Documentation
Return to Kotlin’s “Generics: in, out, where” documentation for the use-site counterpart to the earlier declaration-site rules.
In “Type projections,” find the subsection “Use-site variance: type projections.” Begin with the paragraph opening “It is very easy to declare a type parameter” and read the array example through its final explanation of fill(), stopping before “Star-projections.” Concentrate on why the source array can be read but not written through an out projection, and why an in projection permits safe writes to a suitable destination.
Make the API-design decision explicit
A useful design review question is: Does this generic abstraction have one permanent role, or only one role at this call site?
- If every valid use of an interface only supplies values, declare its parameter
out T, as withStateSource. - If every valid use only accepts values, declare it
in T, as withStateSink. - If the abstraction must do both, leave it invariant. At a boundary that needs only one operation, use a projection such as
Array<out T>orArray<in T>.
Avoid adding a variance modifier merely to make an assignment compile. First identify what the receiving code is allowed to read or write. The modifier should express that contract; the resulting assignments are a consequence of it.
For Android work, this helps keep component boundaries honest. A renderer that only needs to receive states can depend on a state-consuming contract. A component that only supplies values can expose a producing contract. Later, when you build reactive state APIs, the same questions will help you decide what callers may observe and what they may change.
Key takeaways
Generic types are invariant by default because a type that both accepts and returns values cannot generally follow the subtype relationship of its elements. Declaration-site out makes a consistently producing API covariant; declaration-site in makes a consistently consuming API contravariant. Use-site projections grant a safe, restricted view of an otherwise invariant type for one particular use.
The practical test is substitution: What could code do through this reference, and would the underlying object still keep its promises? Next lesson, you’ll use Kotlin’s inline and reified type parameters to address a different generic-API boundary: obtaining type information when runtime casts would otherwise be unsafe.