JavaRush /Courses /Kotlin SELF /Type projections: safe Array<out/in> and projections for...

Type projections: safe Array<out/in> and projections for MutableList

Kotlin SELF
Level 36, Lesson 2
Available

1. Introduction

If we simplify life down to “let the compiler stop being smart,” then sure, you just want to write as and move on. But Kotlin is trying to save us from the situation where the program compiles and then crashes at runtime somewhere far away from the scene of the crime — with that look that says, “I did warn you.”

The problem is that some containers, by their nature, both “read and write.” For such a container, you can’t honestly put out or in on the class declaration — otherwise it loses half its meaning. The classic example is Array<T>, where you can both read by index and write by index. Kotlin explicitly uses this example to explain why Array is invariant and how projections fix it.

Type projections are a compromise: we do not change the Array class, but in a specific function we “trim” its capabilities so the compiler can guarantee safety.

2. Why Array<T> is invariant

With arrays it’s very easy to fall into the trap of “it’s a container, so it should behave like a List.” But List in Kotlin is a read-only interface, while Array is a real “box with index access,” where you can both peek and shove something back in.

Simplifying a bit, you can think of Array<T> as having these operations:

operator fun get(index: Int): T
operator fun set(index: Int, value: T)

That is exactly why Kotlin can’t make Array covariant (like List), otherwise it would become possible to write the wrong type. That’s the reason why a simple “copy from Array<Int> to Array<Any>” does not compile without additional techniques.

Let’s feel it in practice.

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

fun main() {
    val ints: Array<Int> = arrayOf(1, 2, 3)
    val any: Array<Any> = arrayOf("x", "y", "z")

    // copyNaive(ints, any) // does not compile: Array<Int> is not an Array<Any>
}

And that’s good: if the compiler allowed it, inside copyNaive you could theoretically do from[0] = "oops" (i.e., write a String into an Int array). Kotlin blocks this class of errors ahead of time.

3. Projections for Array: out and in

Projection Array<out X>: a source array

When you write Array<out Any> as a function parameter, you tell the compiler: “I take an array that is guaranteed to contain elements at least as good as Any, but I promise: I will not write into it.” In other words, the array becomes a producer — a source of values.

It’s important to catch the philosophy: Array<out Any> is not a new array type. It’s the same array, but through this reference you won’t get access to dangerous write operations (or they become type-impossible).

In the official explanation of type projections, Kotlin uses exactly this idea: “copying from an array we must not modify.”

Writing a safe function copyToAny

Let’s make a tidy copy, where from is read-only.

fun copyToAny(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 dest: Array<Any> = Array(3) { "?" }

    copyToAny(ints, dest)

    println(dest.joinToString()) // 1, 2, 3
}

Notice the magic that doesn’t look like magic: we passed Array<Int> where Array<out Any> is expected. This becomes possible because Kotlin is now sure we won’t write into from, which means we can’t “break” the Int array by writing a String. That is exactly the point of use-site variance / type projection.

What is forbidden in Array<out X>

If you really want to test the compiler’s patience, try:

fun breakIt(from: Array<out Any>) {
    // from[0] = "boom" // does not compile
}

And this is not “Kotlin being stubborn.” Kotlin is honestly enforcing the contract: an out projection means “read, but do not write.”

Projection Array<in X>: a destination array

Now the mirror situation. Sometimes we need an array not as a source, but as a destination: we want to fill it. Then we care about set, while reading is secondary.

Array<in String> reads as: “an array of some supertype of String, into which I can safely put a string.”

Filling an array with strings: fillWithOk

fun fillWithOk(dest: Array<in String>) {
    for (i in dest.indices) {
        dest[i] = "ok"
    }
}

fun main() {
    val anyArray: Array<Any> = Array(3) { 0 }
    fillWithOk(anyArray)

    println(anyArray.joinToString()) // ok, ok, ok
}

Why is this safe? Because Array<Any> can indeed store a String: a string is an Any.

Why reading from Array<in X> becomes weaker

Here’s the key point that irritates many people (and then they get used to it and start getting irritated by other things).

If we have Array<in String>, then you cannot read as String from it: because in reality it could be Array<Any>, and it may contain not only strings. So safe reading typically becomes Any?.

fun showFirst(dest: Array<in String>) {
    val x: Any? = dest[0]
    println("first = $x") // first = ...
}

fun main() {
    val anyArray: Array<Any> = arrayOf(10, 20, 30)
    showFirst(anyArray) // first = 10
}

And again: this is not spite, it’s a guarantee. The in contract says: “I can write String,” but it promises nothing about what’s already inside or what type it is.

4. Projections for MutableList: out and in

We’ve sorted out arrays; now let’s move to something more everyday: MutableList. Conceptually it both reads and writes, so it also can’t be covariant “just because.” But in function parameters we often use MutableList in one of two modes:

  1. as a source of elements (read, but do not modify);
  2. as a destination (add elements).

And this is where use-site variance helps a lot: MutableList<out T> and MutableList<in T>.

It’s important not to confuse this: we are not making MutableList covariant forever. We are saying: “in this function, treat it as a producer / consumer.”

MutableList<out Number>: read numbers, don’t add

Imagine we have a MutableList<Int>, but the function wants to work with Number. Without projections you’d run into type incompatibility (invariance of a mutable collection), but with an out projection it’s fine — as long as you don’t add elements.

fun sumFirstTwo(list: MutableList<out Number>): Double {
    val a = list[0].toDouble()
    val b = list[1].toDouble()
    return a + b
    // list.add(1) // does not compile: out projection forbids writing
}

fun main() {
    val ints: MutableList<Int> = mutableListOf(10, 20, 30)

    println(sumFirstTwo(ints)) // 30.0
}

Here Kotlin protects us from a subtle bug: if writing were allowed, we could add a Double to a Int list, and everything would “go off the rails.”

MutableList<in Int>: add Int to a suitable list

Now the opposite: the function must add Int. It’s useful if we can pass it a list of a more general type, for example MutableList<Number>.

fun addOne(list: MutableList<in Int>) {
    list.add(1)
}

fun main() {
    val numbers: MutableList<Number> = mutableListOf(10, 2.5)

    addOne(numbers)

    println(numbers) // [10, 2.5, 1]
}

The meaning of the contract is: if the list is guaranteed to be able to accept an Int, then its element type is Int or any supertype (Number, Any). That’s exactly the consumer logic.

5. Cheat sheet for out and in

When you start working with projections, your brain protests at first: “why can’t I just do what I want.” To keep your brain from quitting, it helps to keep a short table.

After this paragraph, you can come back to it every time the compiler tells you “you can’t.”

Projection in parameter Idea Convenient to do What Kotlin intentionally makes harder
Array<out T> / MutableList<out T>
source read T write T
Array<in T> / MutableList<in T>
destination write T read as T (usually read as Any?)

This logic follows directly from the idea of type projections (use-site variance) and the fact that Kotlin forbids operations that can break type safety.

6. Example: Finance Tracker and projections on MutableList

To avoid leaving the topic at the level of “abstract dogs and cats,” let’s embed projections into a piece of code that looks like real life.

Imagine we have a console app called Finance Tracker where we store financial transactions. We already know how to store collections of objects, write functions, and work with inheritance, so we’ll make a base operation type and two subtypes.

open class Operation(
    val amount: Double,
    val note: String,
)

class Expense(amount: Double, note: String) : Operation(amount, note)
class Income(amount: Double, note: String) : Operation(amount, note)

Printing operations: producer MutableList<out Operation>

In this function, we only need to read operations and print them. We don’t need to modify the list, otherwise it becomes too “pushy.”

fun printOperations(ops: MutableList<out Operation>) {
    for (op in ops) {
        println("${op::class.simpleName}: ${op.amount} (${op.note})")
        // Example output: Expense: 12.5 (coffee)
    }
}

fun main() {
    val expenses: MutableList<Expense> = mutableListOf(
        Expense(12.5, "coffee"),
        Expense(40.0, "groceries"),
    )

    printOperations(expenses)
}

Why does this matter? Because without out you wouldn’t be able to pass MutableList<Expense> where a “list of operations” is expected. With out you honestly state: the list is used as a source.

Adding an expense: consumer MutableList<in Expense>

Now the reverse task: the function adds an expense. It’s useful if it can add an expense not only to MutableList<Expense>, but also to a general journal MutableList<Operation>.

fun addExpense(ops: MutableList<in Expense>, amount: Double, note: String) {
    ops.add(Expense(amount, note))
}

fun main() {
    val journal: MutableList<Operation> = mutableListOf(
        Income(1000.0, "salary"),
    )

    addExpense(journal, 12.5, "coffee")

    printOperations(journal)
    // Income: 1000.0 (salary)
    // Expense: 12.5 (coffee)
}

From a type perspective this reads like: a MutableList<Operation> is an excellent destination for Expense, because Expense is an Operation.

7. Projections restrict the reference API, not the object

A very common confusion: it feels like Array<out Any> is some “special array.” But in reality the array stays the same. What changes is only what you are allowed to do through this variable.

You can imagine a mini-diagram like this:

flowchart LR
    A["Real object: Array⟨Int⟩"]
    B["Reference as Array⟨out Any⟩"]
    C["Reference as Array⟨Int⟩"]

    A --> B
    A --> C

    B -->|"can read (Any)"| R["get(i)"]
    B -.->|"cannot write"| W["set(i, ...)"]

    C -->|"can read (Int)"| R2["get(i)"]
    C -->|"can write (Int)"| W2["set(i, Int)"]

In this sense, projections are a very “engineering” tool. You don’t rewrite the data model. You give the compiler clear constraints: “in this function I behave responsibly.”

8. Common mistakes

Mistake #1: trying to “append” into Array<out …> or MutableList<out …>.
This usually looks like: you carefully declared the parameter as a producer, and then inside the function you get the urge to say “come on, I’ll add just a little.” The compiler won’t let you. And it’s right: if you could pass a MutableList<Int> as a MutableList<out Number>, then writing 2.5 would be a logical catastrophe.

Mistake #2: expecting that you can read from Array<in …> as if it were Array<T>.
If the parameter is declared as a consumer, Kotlin intentionally makes reading “unpleasant”: the safe read type ends up being something like Any?. That’s normal. If you need both “read as T” and “write T,” then it’s no longer a consumer/producer scenario — and projections may not fit here.

Mistake #3: fixing type incompatibility via as instead of projections.
When you want it to “compile,” your hand reaches for as. But casting in generics often turns a compile-time error into a future runtime surprise. Projections are good because they don’t hide the problem; they force you to state the contract honestly: “read-only” or “write-only.”

Mistake #4: confusing projections with out/in in the type declaration.
out and in in a type declaration are a design decision for your class or interface (“this type is a producer by nature”). Projections in function parameters are a localized decision (“in this function I use the container as a producer/consumer”). In practice these are different levels, and it helps to say to yourself explicitly: “am I designing a type, or just writing a safe function signature?”

Mistake #5: trying to make a universal function that both reads and writes.
Sometimes you want a function that “copies and tweaks a bit,” and you want the parameters to be as wide as possible. But invariance says: if you want to both read and write, please accept the exact type (Array<T>, MutableList<T>). And if you want to accept a wider type, you’ll have to sacrifice something (usually the ability to write or the precise read type).

1
Task
Kotlin SELF, level 36, lesson 2
Locked
Moving boxes
Moving boxes
1
Task
Kotlin SELF, level 36, lesson 2
Locked
Filling labels
Filling labels
1
Task
Kotlin SELF, level 36, lesson 2
Locked
Summing Sensor Readings
Summing Sensor Readings
1
Task
Kotlin SELF, level 36, lesson 2
Locked
Appending numbers
Appending numbers
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION