JavaRush /Courses /Kotlin SELF /Subtyping and generics: why List “lifts”, but MutableList...

Subtyping and generics: why List “lifts”, but MutableList does not

Kotlin SELF
Level 36, Lesson 0
Available

1. Introduction

When we talk about “subtyping”, we usually recall inheritance: one type extends another. And in ordinary (non-generic) life everything is fairly linear: if Expense inherits from Transaction, then a variable of type Transaction can store a reference to an Expense. This is the basis of polymorphism: “you can substitute a subclass where a base class is expected”.

Let’s lock this in with a small code fragment from our hypothetical personal finance tracking app (let’s call it BudgetTracker). Here, a transaction is the base type, and an expense is a specific kind of transaction:


open class Transaction(val amount: Int, val comment: String)

class Expense(amount: Int, comment: String, val category: String) :
    Transaction(amount, comment)

fun main() {
    val t: Transaction = Expense(500, "Coffee and croissant", "Food")
    println(t.comment) // Coffee and croissant
}

This looks logical: an expense is a transaction, so a “transaction” variable can reference it. And right here it’s easy to take the next “intuitive step”… which turns out to be not always correct once angle brackets appear.

2. Why Box<Expense> is not Box<Transaction>

As soon as we take a type T and start wrapping it into a generic container Box<T>, we get a new entity: not T, but “a box of T”. And here Kotlin, by default, chooses the most cautious stance: generic types are invariant by default, meaning “we don’t promise any automatic substitutions based on inheritance”.

Let’s make the simplest “box”:

class Box<T>(var value: T)

open class Transaction(val amount: Int)
class Expense(amount: Int) : Transaction(amount)

fun main() {
    val expenseBox: Box<Expense> = Box(Expense(100))

    // Not allowed (and it’s not a whim):
    val txBox: Box<Transaction> = expenseBox
}

At a human level you want to say: “Well, Expense is a subclass of Transaction, so a box of expenses is a box of transactions.” But the compiler disagrees. And it’s not being stubborn: it prevents a situation where, through a “box of transactions”, we suddenly put something else in there (for example, income), and break the assumption that what’s inside is specifically an Expense.

At this point it helps to remember a short thought: subtyping of elements does not have to carry over to subtyping of containers. Next we’ll see why Kotlin makes a nice exception for List—but not for MutableList.

3. Why List<Expense> can be used as List<Transaction>

An important point is coming up that people often confuse in Kotlin: List is not “just a list”, it is a read-only interface. It provides read operations: get the size, get an element by index, iterate, but it does not provide “add” or “remove”. Kotlin deliberately separates collections into read-only and mutable interfaces: there is an interface for reading and a separate interface for read+write.

And here is the key: read-only collections in Kotlin are covariant, meaning List<Rectangle> can be used where List<Shape> is expected if Rectangle inherits from Shape. In plain language: “if the list is read-only, then a list of more specific elements can be treated as a list of more general ones”.

Let’s apply this to our transactions:

open class Transaction(val amount: Int, val comment: String)
class Expense(amount: Int, comment: String) : Transaction(amount, comment)

fun main() {
    val expenses: List<Expense> = listOf(
        Expense(120, "Coffee"),
        Expense(350, "Taxi")
    )

    val transactions: List<Transaction> = expenses

    println(transactions[0].comment) // Coffee
}

Why is this safe? Because through the variable transactions you cannot do transactions.add(...)—List simply has no such operation. That means you cannot “mix” some other transaction into the original list of expenses. You only read what’s already there.

To reinforce this, it helps to compare the contracts:

Type Can read elements Can add/remove
List<T>
yes no
MutableList<T>
yes yes

And it is exactly because of this read-only interface that Kotlin allows List to be “lifted” up the hierarchy.

Important: read-only is not the same as “immutable forever”. List does not promise the data will never change somewhere else; it promises that through this particular reference type you won’t be able to mutate the collection. Kotlin describes precisely this model of read-only vs mutable interfaces.

4. Why MutableList<Expense> cannot be used as MutableList<Transaction>

Now we reach the most “painful” part: why the compiler forbids something that seems so logical. It all comes down to one thing: MutableList allows writing.

Imagine Kotlin allowed the following line:

open class Transaction(val amount: Int)
class Expense(amount: Int) : Transaction(amount)
class Income(amount: Int) : Transaction(amount)

fun main() {
    val expenses: MutableList<Expense> = mutableListOf(Expense(100))

    // If this were allowed...
    val txs: MutableList<Transaction> = expenses
    txs.add(Income(5000)) // added income to the “transactions list”

    // ...then expenses would now contain an Income, breaking the type!
}

If the variable txs is a “list of transactions”, then according to its contract adding Income(...) is perfectly legal. But if under the hood it is the same object as expenses (a list of expenses), then we just silently mixed income into the expenses list. That violates type safety: MutableList<Expense> is no longer a “real list of Expense”.

That’s why Kotlin enforces a strict rule: mutable collections are not covariant, otherwise it would lead to runtime problems and type violations.

In practice you’ll see an error roughly like this (the exact text may differ slightly, but the idea is the same):

Type mismatch: Required `MutableList<Transaction>`, found `MutableList<Expense>`

And this is exactly the case where the compiler is not “getting in your way”, but actually preventing future data corruption.

5. Mental model: “output” vs “intake”

So you don’t have to memorize everything like a spell, it helps to keep a simple everyday analogy in mind. Imagine there is a warehouse and you come to the service window. In one case you are allowed only to receive goods (“output”), in another case only to accept goods (“intake”), and in the third case both receive and accept (and then things get fun, because someone will definitely bring the wrong thing).

Read-only List is almost “output”: you take elements and read them. So a “warehouse of expenses” can be treated as a “warehouse of transactions”: any expense is a transaction, therefore reading them as transactions is safe.

MutableList is “output and intake at the same time”: you can both take an element and put a new one in. And here substituting types becomes dangerous: if you are given an “intake for transactions”, you can bring income there; but if it was actually an “intake for expenses”, you get chaos.

A small diagram to make it stick:

flowchart LR
    A["List⟨Expense⟩
(read-only)"] --> B["List⟨Transaction⟩
(read-only)"] C["MutableList⟨Expense⟩
(read+write)"] -.-x D["MutableList⟨Transaction⟩ (read+write)"]

A solid arrow means “safe to substitute”; a dotted line with an X means “dangerous, therefore forbidden”.

6. Practice in BudgetTracker: how to accept the “right” lists

In real code, the pain from MutableList<Expense> usually doesn’t show up in abstract examples, but when you write a useful function and want to “just accept a list”. For example: compute a sum, print to the console, find a maximum, sort, group.

And here is the main practical advice that follows directly from Kotlin’s model: if a function does not need to change the collection, accept List, not MutableList. This immediately makes your API more flexible, and you automatically get that “lifting” of types.

Let’s make a base type and two operations in our app: printing and summing. Neither mutates the list.

open class Transaction(val amount: Int, val comment: String)
class Expense(amount: Int, comment: String) : Transaction(amount, comment)

fun printTransactions(items: List<Transaction>) {
    for (t in items) {
        println("${t.amount} — ${t.comment}")
    }
}

fun main() {
    val expenses: List<Expense> = listOf(
        Expense(120, "Coffee"),
        Expense(350, "Taxi")
    )

    printTransactions(expenses)
    // 120 — Coffee
    // 350 — Taxi
}

Notice the “magic without magic”: printTransactions expects List<Transaction>, and we passed List<Expense>, and everything is fine—because List is read-only and covariant.

Now an example of a function that wants to mutate the list—for instance, add a transaction. Here you can’t “just accept anything”:

open class Transaction(val amount: Int, val comment: String)
class Expense(amount: Int, comment: String) : Transaction(amount, comment)

fun addTestTransaction(storage: MutableList<Transaction>) {
    storage.add(Expense(10, "Test expense"))
}

fun main() {
    val txs: MutableList<Transaction> = mutableListOf()
    addTestTransaction(txs)

    println(txs.size) // 1
}

And here is an important observation: if somewhere you store a MutableList<Expense>, you won’t be able to pass it into addTestTransaction, because that would be potentially unsafe (we already discussed why).

One temporary “survival tactic”, while we’re not yet learning special type-system tools (we’re not touching them today), is to make a copy when you need to “mutate temporarily”:

open class Transaction(val amount: Int)
class Expense(amount: Int) : Transaction(amount)

fun addSomething(storage: MutableList<Transaction>) {
    storage.add(Expense(999))
}

fun main() {
    val expensesOnly: List<Expense> = listOf(Expense(100))

    val copyAsTransactions: MutableList<Transaction> = expensesOnly.toMutableList()
    addSomething(copyAsTransactions)

    println(copyAsTransactions.size) // 2
}

Yes, it’s copying. Yes, it costs memory and time. But it’s honest: we don’t break the original list expensesOnly, and we work with a separate mutable copy. This is a normal “bridge” approach while you’re not yet equipped with more precise type contracts.

How to read compiler messages about List and MutableList

Compiler messages in these places are usually short, but frustrating. And the biggest beginner problem is treating them as an “unexplained prohibition” rather than a hint about what can go wrong.

If you see that List<Expense> doesn’t fit List<Transaction>, then most likely you are actually using not List, but a mutable type somewhere (or expecting one). In Kotlin, read-only collections are covariant, and mutable ones are not. So the first thing to do is check the function signature: do you really need MutableList there?

If the message says it requires MutableList<Transaction> and you have MutableList<Expense>, it almost always means: “I can see you want to write into the collection, and that’s unsafe with this substitution.” Then you have a choice: either change the design so the function accepts List and doesn’t mutate; or store the data differently; or explicitly copy the collection into a more general type, as we showed above.

And it’s also useful to remember: Kotlin clearly separates read-only and mutable interfaces for collections. Sometimes it’s enough to change MutableList to List in a function parameter—and half of the “weird” problems suddenly disappear.

7. Analogy with functions: why (Transaction) -> String fits (Expense) -> String

There is one more interesting piece that helps your brain get used to the idea that “substitution doesn’t always work the way it seems”. That’s function types.

In Kotlin, a function is also a value, and it has a type like (A) -> B: it takes A and returns B. A very logical idea applies here: if a function can accept any transaction, then it can definitely accept an expense too (because an expense is a special case of a transaction).

From the type system’s point of view this looks “reversed” compared to lists: for the input parameter, the function behaves as if it “allows substitution in the other direction”.

open class Transaction(val comment: String)
class Expense(comment: String) : Transaction(comment)

fun main() {
    val formatTransaction: (Transaction) -> String = { t ->
        "TX: ${t.comment}"
    }

    val formatExpense: (Expense) -> String = formatTransaction

    println(formatExpense(Expense("Coffee"))) // TX: Coffee
}

Why is this safe? Because formatTransaction is ready for any Transaction. If you give it an Expense, it won’t get confused: “ok, that’s also a transaction.” But if we tried to do the opposite (substitute a function that can format only expenses where any transaction might show up) it would be dangerous: it might be given income, and it did not “promise” to handle it.

This example is not here so that you immediately learn the word “contravariance” and drop it in an interview. It’s here so you accept the idea: subtyping for generic constructions depends on whether we read values or write them. For functions, “read/write” shows up as “accept an argument / return a result”.

8. Typical mistakes

Mistake #1: “If it’s a List, then it’s immutable.”
Beginners often think List means an “immutable list”. In reality, List is a read-only interface: you can’t modify it through this reference type, but the underlying collection may be mutable elsewhere. Kotlin describes the model exactly this way: there is a read-only interface, and there is a mutable interface that extends it with write operations.

Mistake #2: Writing functions that change nothing, but accept MutableList.
This is not just “style”. It’s a practical trap: you lose the ability to pass List<Expense> to places that expect MutableList<Transaction>, and you get type errors. If you’re not going to add/remove elements, accept List, and you automatically get safe “lifting” of types for read-only collections.

Mistake #3: Trying to “cheat” the system using as casts.
Sometimes there’s a temptation to do something like val x = expenses as MutableList<Transaction> so that it “compiles”. This is exactly the path that leads to runtime problems: you manually disable the compiler’s safety net. If the code is actually unsafe, you’re just postponing the crash, making it more unpleasant and less obvious.

Mistake #4: Treating “lifts” and “doesn’t lift” as an arbitrary rule.
It’s easy to learn “List works, MutableList doesn’t” like a magic spell. But it’s much more useful to keep the reason in mind: read-only collections are covariant because you cannot write through them, while mutable collections are not covariant, otherwise you can violate the element type (mix in a different subclass). Kotlin states this motivation explicitly.

1
Task
Kotlin SELF, level 36, lesson 0
Locked
Shelter roster
Shelter roster
1
Task
Kotlin SELF, level 36, lesson 0
Locked
Shapes catalog
Shapes catalog
1
Task
Kotlin SELF, level 36, lesson 0
Locked
Transaction Box
Transaction Box
1
Task
Kotlin SELF, level 36, lesson 0
Locked
Ledger copy
Ledger copy
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION