1. Introduction
When you first encounter out and in, it’s very tempting to think: “Aha, this is some clever magic to make generics work like inheritance.” But it’s more accurate to think the other way around: out/in is a way to prevent you from doing something unsafe—even if you really want to, even if “I’ll be careful.”
Imagine you’re building a small module for our training console app (let’s say it’s an expense tracker). We want to write code that works with Record (a base record), ExpenseRecord (an expense), and IncomeRecord (an income). We already know inheritance, and we want “containers” to adapt as well.
The problem is that containers come in different kinds. Some containers are a “box” from which we take out a value. Others are a “bin/sink” into which we put values. And some are “both,” and that’s usually where invariance and restrictions begin.
The key idea of today’s lecture: declaration-site variance (declaration-site variance) is when, in the declaration of your generic type, you honestly tell the compiler: “Look, I will use T only in this way.” Kotlin trusts you—and in return allows more flexible subtyping. This is described in the official documentation as declaration-site variance and is shown with the example Source<out T>: if the type really only “produces” T, it can be made covariant via out.
2. Declaration-site variance: how to declare out/in and what type positions are
Now we’ll write our own generic types, and it looks almost like “access configuration”: you’re creating an API where dangerous operations are forbidden up front.
An important point: out and in are written at the type-parameter declaration site, i.e., in the header of a class/interface/function type, for example class Box<out T> ... or class Sink<in T> .... This is not a “one-off trick for a single function,” but a rule for the whole type:
- if T is declared as out, Kotlin will strictly ensure that T is never used as an “input” (in method parameters, setters, etc.);
- if T is declared as in, Kotlin will ensure you don’t try to “expose” a value of type T as the exact type.
Why so strict? Because otherwise variance would break at runtime (as Java arrays once did, and it still hurts a bit). Kotlin prefers: “better to not compile than to give you a chance to cause a type disaster.”
It’s useful to understand how the compiler decides where T is “in” and where it’s “out”. In practice, this is about what happens to the value: do you give it outward or accept it inward?
Below is a small cheat-sheet table. It doesn’t replace understanding, but it helps a lot when you look at a compiler error and think, “what does it want now?”
| Where T appears | This T as… | Example | Allowed with out T | Allowed with in T |
|---|---|---|---|---|
| Function return type | out (we expose outward) | |
yes | no |
| Function parameter | in (we accept inward) | |
no | yes |
| val property of type T | out (getter) | |
yes | no |
| var property of type T | both out and in | |
no | no (in the general case) |
There’s an important “in the general case” here: sometimes you can work around the restriction by changing the design. For example, a consumer can store values as Any?, because Any? does not promise the type T. A producer can accept not a T, but something else that is not tied to the type parameter (for example, “reset state”).
3. out T: covariance and a producer
Now we’ll look at out in practice—and it will be the most “intuitive” option, because it resembles List, which you’ve already been living with. After that, it will be easier to understand in, which usually bends the brain a bit more.
Mini domain for examples
So that the examples aren’t “about cats and dogs” (even though cats and dogs try), let’s make types similar to what might live in our finance-tracking app:
open class Record(val id: Int, val comment: String)
class ExpenseRecord(
id: Int,
comment: String,
val amount: Int
) : Record(id, comment)
Here ExpenseRecord is a subtype of Record. So ExpenseRecord can be used where Record is expected (this is ordinary inheritance, which we already had).
A “snapshot” as a producer: Snapshot<out T>
Now let’s create a type that only stores a value and returns it. This is very similar to a “snapshot of a computation result,” a “server response,” a “ready value.”
class Snapshot<out T>(private val value: T) {
fun get(): T = value
}
Note: T here is used only in an out position—it’s returned from get(). That’s exactly the promise that out requires.
Let’s check subtyping:
fun main() {
val expenseSnapshot: Snapshot<ExpenseRecord> =
Snapshot(ExpenseRecord(1, "Coffee", 250))
val recordSnapshot: Snapshot<Record> = expenseSnapshot
println(recordSnapshot.get().comment) // Coffee
}
Why is this safe? Because through recordSnapshot we cannot push an “incorrect Record” inside. We can only read, and reading Record from Snapshot<ExpenseRecord> is perfectly fine: an “expense” is also a “record.”
This exact logic is why Kotlin read-only collections are covariant (they are “sources of values”). The documentation explicitly notes that read-only collections are covariant.
What out forbids: you can’t accept T as input
Now let’s try to “improve” Snapshot: add a method set(newValue: T) so we can change the value.
class Snapshot<out T>(private var value: T) {
fun get(): T = value
// Not allowed — an idea example; does not compile:
// fun set(newValue: T) { value = newValue }
}
Kotlin will forbid this (and that’s good). The logic is: if Snapshot<ExpenseRecord> can be assigned to Snapshot<Record>, and then via a variable of type Snapshot<Record> you could call set(Record(...)), then you would put a “plain Record” into an object that actually stores ExpenseRecord. Type safety would fall apart.
So out is the price of flexibility: you get “upcasting” along the type hierarchy, but you lose the ability to safely accept T as input.
Trap: var almost always breaks out
One of the most common surprises for beginners: you declared out T, and then you made a property var value: T, and suddenly the compiler is unhappy. The reason is that var is both a getter and a setter. The getter returns T (out position), the setter accepts T (in position). But out requires that T be used only as out.
Look at this “idea example”:
// Does not compile — shown as a trap:
// class Box<out T>(var value: T)
If you need a “producer,” usually use val (read-only) or hide the storage internally and expose it via methods.
4. in T: contravariance and a consumer
If out usually feels “reasonable,” then in at first feels like “the compiler is messing with me.” Let’s make it so the compiler looks not like a villain, but like a bouncer at the type-safety club.
The consumer idea: we only accept T
Let’s build a type that accepts values of type T, but does not expose anything of type T outward.
For example, a “formatter” (it receives an object and turns it into a string). This is very practical for our console app: we print things all the time.
class Formatter<in T>(private val toText: (T) -> String) {
fun format(value: T): String = toText(value)
}
Note: T here appears in the parameter format(value: T)—this is an in position. And T also appears in the input of the function (T) -> String, and a function input also behaves like “in.”
Why subtyping “reverses”
Now the most interesting part: what is a subtype of what?
If ExpenseRecord is a subtype of Record, then Formatter<Record> is a subtype of Formatter<ExpenseRecord>, because Formatter is a consumer (contravariant).
It sounds strange, but the logic is simple: if an object can format any Record, then it definitely can format an ExpenseRecord (because an expense is a special case of a record).
fun main() {
val recordFormatter: Formatter<Record> =
Formatter { r -> "#${r.id}: ${r.comment}" }
val expenseFormatter: Formatter<ExpenseRecord> = recordFormatter
val e = ExpenseRecord(10, "Taxi", 900)
println(expenseFormatter.format(e)) // #10: Taxi
}
We assigned “in the opposite direction” compared to out. And that’s fine: for consumers, everything is mirrored.
What in forbids: you can’t return T outward as an exact type
Let’s try to make a consumer that also “stores the last accepted element” and returns it outward:
class Sink<in T> {
// Idea that will not compile if you try to return T:
// private var last: T? = null
// fun put(x: T) { last = x }
// fun last(): T? = last
}
Why not? Because if you have Sink<Record> and assign it to a variable Sink<ExpenseRecord> (which is allowed with in), then someone will start thinking that last() returns ExpenseRecord?. But the real object is Sink<Record>—you could have put any Record in there, not necessarily an expense.
So Kotlin allows consumer types to accept T, but does not allow “promising” the exact T outward.
5. Where you’ve already seen out/in in Kotlin
A nice part: you’ve already used variance; you just didn’t notice. Kotlin tries to make the “correct” variance available in the standard library out of the box, so you don’t have to keep writing out/in manually.
List<out T> as a read-only producer
Read-only List is covariant, and therefore List<ExpenseRecord> can be used as List<Record>. That’s exactly the producer logic we discussed above. In the documentation it’s phrased like this: read-only collections are covariant.
Comparable<in T> as a consumer
For Comparable, the type parameter is contravariant: Comparable<in T>. In the documentation, the example is based on the fact that Comparable<Number> can be assigned to Comparable<Double>, because an object that can compare Number can also compare Double.
Even if you don’t yet want to dive deep into the math of comparisons, it’s useful to remember the shape: the compared type is in the input of compareTo(other: T), so this is a consumer → in.
Function types: input is in, output is out
The function type (A) -> B is a very clear model of variance: a function consumes an argument of type A and produces a result of type B. So the input behaves like in, and the output behaves like out.
A practical thought: if you have a function that can accept a Record, then it can automatically accept an ExpenseRecord as well (because an expense is a record). So such a function can be used where an expense handler is expected:
fun main() {
val handleRecord: (Record) -> String = { r -> "Handled: ${r.comment}" }
val handleExpense: (ExpenseRecord) -> String = handleRecord
println(handleExpense(ExpenseRecord(7, "Lunch", 600))) // Handled: Lunch
}
If you just thought “this looks like an example from the previous lecture”—yes. Now you simply see that it’s not an accident: it’s the same consumer/producer principle, just expressed with functions.
6. Practical refactoring: a data source and an output handler
So today’s topic doesn’t stay “in a vacuum,” let’s carefully integrate it into the console app logic. We won’t build “architecture for ages” (that’s a separate discipline), but we’ll do one small, understandable thing: separate data retrieval from output formatting using out/in.
Report-data producer: ReportSource<out T>
Let’s make a source that produces elements for a report. It’s a producer, so out.
class ReportSource<out T>(private val items: List<T>) {
fun firstOrNull(): T? = items.firstOrNull()
}
Use it:
fun main() {
val expenses = listOf(
ExpenseRecord(1, "Coffee", 250),
ExpenseRecord(2, "Book", 1200)
)
val expenseSource: ReportSource<ExpenseRecord> = ReportSource(expenses)
val recordSource: ReportSource<Record> = expenseSource
println(recordSource.firstOrNull()?.comment) // Coffee
}
Everything is logical here: an expense source can be used as a record source because it only “produces.”
Output consumer: Printer<in T>
Now let’s make a “printing” object that accepts T and prints to the console. It’s a consumer, so in.
class Printer<in T>(private val toLine: (T) -> String) {
fun print(value: T) {
println(toLine(value))
}
}
And verify type substitution:
fun main() {
val printRecord = Printer<Record> { r -> "Record: ${r.comment}" }
val printExpense: Printer<ExpenseRecord> = printRecord
printExpense.print(ExpenseRecord(3, "Taxi", 900)) // Record: Taxi
}
An object that can print “any Record” fits where we need to print “only ExpenseRecord.” That is contravariance with in.
Connecting producer and consumer in one function
Now we can write a function that takes a “source” and a “printer” and prints the first element. Here it’s useful to see how out and in start to cooperate in one place:
fun printFirst(
source: ReportSource<Record>,
printer: Printer<Record>
) {
val first = source.firstOrNull()
if (first != null) {
printer.print(first)
}
}
And now we’ll use it by substituting “more specific” and “more general” things where it’s safe:
fun main() {
val expenses = listOf(ExpenseRecord(1, "Coffee", 250))
val sourceExpenses = ReportSource(expenses) // ReportSource<ExpenseRecord>
val printerRecords = Printer<Record> { "-> ${it.comment}" }
printFirst(sourceExpenses, printerRecords) // -> Coffee
}
Why does this even compile if printFirst expects ReportSource<Record>, but we pass ReportSource<ExpenseRecord>? Because ReportSource is declared as out, i.e., it’s covariant. Why does the printer compile? Because Printer is declared as in, i.e., it’s contravariant.
7. Common mistakes when designing with out and in
Mistake #1: put out and then try to build a “normal mutable container.”
A very common situation: you write class Box<out T>, and then add fun set(x: T) or make a var value: T. The compiler complains, and it feels like it’s “ruining your life.” In reality, it protects you from a logical hole: a covariant type cannot be safely used as a receiver of T. If you need mutability, either make the type invariant (class Box<T>), or split roles into producer and consumer as separate types.
Mistake #2: put in and then try to return T outward.
With a consumer type, you often want to “return the last accepted element.” But once the type becomes contravariant, returning T becomes an unsafe promise: from the outside, callers will expect a more specific type than what could actually have gone inside. In such cases, either return something more general (Any?) or change the design: a consumer does not have to be able to “take back” what it accepted.
Mistake #3: think that out makes an object immutable.
out does not mean “immutable.” It means “in my API, the type parameter is used only on output.” You can absolutely have methods without T that mutate internal state (for example, clear()), and the type will still be covariant. Variance guarantees only type safety, not the absence of mutation—these are different concepts.
Mistake #4: try to choose out or in “at random” just to make it compile.
out and in are not a “cure for type errors,” but a meaning statement. A good way to stop guessing is to say out loud every time: does my type produce T or consume T? If it produces (we read, get, return) — out. If it consumes (accepts, adds, processes input) — in. If it does both — most likely invariance, and you’ll need to design differently.
Mistake #5: be surprised that var breaks both out and in.
A var property x: T both returns T (getter) and accepts T (setter). So in a type with out T it’s forbidden because of the setter, and in a type with in T it’s forbidden because of the getter. If you want to store a value but keep variance, it usually helps to: for out use val, and for in store not T but a more general type (Any?) or not store at all (make a “pure handler”).
GO TO FULL VERSION