1. Model immutability and copy()
If you’re just getting started, immutability may sound like “a restriction for the sake of restriction”: why not just modify the object in place? The problem is that programs grow, and along with them grows the number of places where the same object is read and modified. At some point you catch a bug like: “I changed the expense amount in one place, and the report broke somewhere else, even though I didn’t even look there.”
Immutability is an attempt to make data behave like a store receipt: you can rewrite it (create a new one), but you can’t “erase a digit with a pen” in a way nobody notices.
Let’s first feel the difference on a very simple example between “modifying an object” and “creating an updated version.”
Imagine we have a user model:
data class User(val name: String, val age: Int)
fun main() {
val u1 = User("Ann", 20)
// u1.age = 21 // not allowed: age is a val
val u2 = u1.copy(age = 21)
println(u1) // User(name=Ann, age=20)
println(u2) // User(name=Ann, age=21)
}
Nothing “magical” happens here: we don’t modify u1, we create a new object u2 that differs only in age. This approach makes the change history obvious: there was one state, and now there is another.
What copy() does in a data class
In a data class, the compiler generates copy() automatically. The idea is simple: you copy an object, but you can replace some of its fields.
It’s important that copy() usually has parameters with default values equal to the object’s current values. This is often explained as equivalent to a “manual” implementation like copy(name = this.name, age = this.age).
So copy() is like “Save As…”, but for an object: you take the current version of the data and create a new version, changing specific fields.
Quick sanity check of “what is actually happening”:
data class Config(val host: String, val port: Int)
fun main() {
val base = Config(host = "localhost", port = 8080)
val prod = base.copy(host = "prod.company")
println(base) // Config(host=localhost, port=8080)
println(prod) // Config(host=prod.company, port=8080)
}
Notice how readable this is: we clearly see that we’re changing only host. This is especially nice when there are 5–10 fields and you don’t want to recreate an object “from scratch” every time.
2. Updating data via object versions
When you switch to copy(), it helps to change the mental model: a data model is not a “living organism that we keep editing,” but a “snapshot of state.” Snapshots can be stored, compared, logged, and passed further along.
This makes debugging much easier: if something goes wrong, you can print “before” and “after,” and they really will be different objects.
In our practical console app (expense tracking), we’ve already come to the idea that it’s convenient to store expenses as objects (after moving away from Pair/Triple). So let’s lock in a simple Expense model and practice updates via copy().
data class Expense(
val id: Int,
val title: String,
val amountCents: Long,
val category: String
)
fun main() {
val e1 = Expense(id = 1, title = "Coffee", amountCents = 350, category = "Food")
val e2 = e1.copy(amountCents = 450)
println(e1) // Expense(id=1, title=Coffee, amountCents=350, category=Food)
println(e2) // Expense(id=1, title=Coffee, amountCents=450, category=Food)
}
Now an important practical question: “OK, I created a new object. How do I update the expense list?” There are two popular approaches—both are fine at our level.
Update an element in a MutableList: replace the object entirely
We store expenses in a MutableList<Expense> and replace an element by index, but we replace it entirely with a new object (rather than changing fields inside the object).
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val expenses = mutableListOf(
Expense(1, "Coffee", 350),
Expense(2, "Taxi", 1200)
)
expenses[0] = expenses[0].copy(amountCents = 450)
println(expenses[0]) // Expense(id=1, title=Coffee, amountCents=450)
}
Update the list functionally: create a new collection
We create a “new list” via map without touching the old one. This is useful when you want a more functional style and minimal mutation.
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val expenses = listOf(
Expense(1, "Coffee", 350),
Expense(2, "Taxi", 1200)
)
val updated = expenses.map { e ->
if (e.id == 1) e.copy(amountCents = 450) else e
}
println(updated[0]) // Expense(id=1, title=Coffee, amountCents=450)
}
Both approaches use the same idea: “update = create a new version of the object.” The only difference is whether you update the container (the list) in place or create a new collection.
To keep your brain from melting, you can keep a small table in mind:
| What changes | Approach | What mutates | What remains immutable |
|---|---|---|---|
| One element in a MutableList | |
the list (the slot) | the object inside (we replaced it with a new one) |
| The whole collection | |
nothing (we created a new one) | the old list and the old objects |
Why copy() usually works better with val than with var
A very common beginner mistake looks like this: a student makes a data class with a bunch of var because “well, I’m going to change fields.” And it kind of works… until confusion starts: who changed which field and where, and why the report shows weird values.
With val, the model behaves like a “stable value”: if you hold a reference to an Expense object, you can be sure it won’t change under your feet. This is especially important when:
- you pass the object into functions,
- you put the object into collections,
- you compare objects with ==,
- you print the object to a log (and want the log to match reality).
copy() and val are like “an editor without a Save button that overwrites the old file”: you always save a new version. Yes, at first it feels like extra steps. But in practice, it saves time investigating “who ruined the data.”
Let’s compare a “mutable” and a “versioned” model in a short example:
data class MutableUser(var name: String, var age: Int)
data class StableUser(val name: String, val age: Int)
fun main() {
val a = MutableUser("Ann", 20)
a.age = 21
println(a) // MutableUser(name=Ann, age=21)
val b1 = StableUser("Bob", 20)
val b2 = b1.copy(age = 21)
println(b1) // StableUser(name=Bob, age=20)
println(b2) // StableUser(name=Bob, age=21)
}
The second option clearly shows “before/after” and doesn’t let you accidentally change b1. That’s the main practical value.
3. Shallow copy: when copy() shares nested references
This is where the most important (and most treacherous) part of this lecture begins: copy() makes a shallow copy (shallow copy). That means: copy() copies field values, but it does not “go inside” nested objects. Components are not copied recursively, and references to other objects may be shared.
In plain language: if your object contains something mutable (for example, a MutableList), then both the original and the copy will point to the same list.
Let’s reproduce the classic surprise:
data class Team(val name: String, val members: MutableList<String>)
fun main() {
val a = Team("A", mutableListOf("Ann"))
val b = a.copy()
b.members.add("Bob")
println(a.members) // [Ann, Bob]
println(b.members) // [Ann, Bob]
}
This is not a Kotlin bug. It’s honest reference math: both a.members and b.members point to the same MutableList.
So it doesn’t feel like “I was lied to,” let’s draw a diagram:
flowchart LR
A["a: Team"] --> LA["members -> List#1"]
B["b: Team (copy)"] --> LA
LA --> M1["Ann"]
After b.members.add("Bob"), the element is added to List#1, which means both objects “see” it.
How to live with shallow copy in this course
We have a simple practical conclusion: if you want to actively use copy() as your update style, try to design your data model so it doesn’t contain “hidden mutations.”
The easiest way to achieve this is discipline: inside the model, store List (a read-only interface) instead of MutableList, and apply changes by creating a new list. This fits with what we’ve already learned about collections and the fact that copying collections is usually shallow as well: elements remain the same objects.
For example, let an expense have tags, but we won’t expose a “live” mutable collection to the outside:
data class Expense(
val id: Int,
val title: String,
val tags: List<String>
)
fun main() {
val e1 = Expense(1, "Coffee", tags = listOf("food"))
val e2 = e1.copy(tags = e1.tags + "morning")
println(e1.tags) // [food]
println(e2.tags) // [food, morning]
}
The + operator for List creates a new list with the added element. In other words, you again create a “new version of the data” rather than mutating the old one—and that pairs very well with copy().
4. Practice: updating an expense without mutation
Now we’ll assemble a small but complete piece of logic for our console expense tracker. The idea is: we have a list of expenses, and we want to “update the amount” or “rename the title” without touching the object directly—only via copy().
This will be a bit more verbose, but it will be predictable: the function doesn’t modify what you pass in; it returns a new version.
Let’s start with a minimal model:
data class Expense(
val id: Int,
val title: String,
val amountCents: Long,
val category: String,
val tags: List<String>
)
Now let’s make a function “update amount” (note: input is an Expense, output is a new Expense):
fun withNewAmount(expense: Expense, newAmountCents: Long): Expense {
require(newAmountCents >= 0) { "Amount must be non-negative: $newAmountCents" }
return expense.copy(amountCents = newAmountCents)
}
And test it in main:
fun main() {
val e1 = Expense(1, "Coffee", 350, "Food", tags = listOf("morning"))
val e2 = withNewAmount(e1, 450)
println(e1.amountCents) // 350
println(e2.amountCents) // 450
}
Now a variant: “update an expense in the list by id.” The list is mutable, but we replace the object entirely:
fun updateAmountById(expenses: MutableList<Expense>, id: Int, newAmountCents: Long) {
val index = expenses.indexOfFirst { it.id == id }
if (index == -1) return
expenses[index] = expenses[index].copy(amountCents = newAmountCents)
}
Quick check:
fun main() {
val expenses = mutableListOf(
Expense(1, "Coffee", 350, "Food", listOf()),
Expense(2, "Taxi", 1200, "Transport", listOf())
)
updateAmountById(expenses, id = 1, newAmountCents = 450)
println(expenses[0]) // Expense(id=1, title=Coffee, amountCents=450, category=Food, tags=[])
}
Now let’s add “add tag” (again, without mutation, because tags: List<String>):
fun addTag(expense: Expense, tag: String): Expense {
val clean = tag.trim()
if (clean.isEmpty()) return expense
return expense.copy(tags = expense.tags + clean)
}
And a test:
fun main() {
val e1 = Expense(1, "Coffee", 350, "Food", tags = listOf("morning"))
val e2 = addTag(e1, "cafe")
println(e1.tags) // [morning]
println(e2.tags) // [morning, cafe]
}
If you’re thinking now, “But that’s a lot of copies!”—yes, there will be more copies. But in return you get code where data changes read like text: “take an expense → make a version with a new amount → put it into the list.”
For a learning project and most typical applications, this is a very reasonable trade-off between simplicity and reliability.
For clarity, you can visualize the update like this:
flowchart TD
A["Expense (old)"] -->|"copy(amountCents=...)"| B["Expense (new version)"]
B -->|replaced in list| C["MutableList⟨Expense⟩ (slot updated)"]
5. Common mistakes when working with copy() and immutability
Mistake #1: expecting a “deep copy” from copy().
It’s very easy to write val b = a.copy() once and subconsciously expect that now “everything is independent.” But copy() in a data class is a shallow copy: nested references (for example, to a MutableList) will be shared, and changes in one place will show up in another.
Mistake #2: storing MutableList/MutableMap in the model while building your architecture around copy().
A mutable collection is not forbidden by itself. The problem starts when you think “I update the object only via copy(),” but inside there is a mutable structure that someone modifies directly. Then you get a hybrid: immutability on the outside, hidden mutation on the inside—and those bugs are very unpleasant to track down.
Mistake #3: using positional arguments in copy() and mixing up the order.
Technically, you can call copy() positionally, but as soon as there are more than two fields, the chance of making a mistake increases sharply. It’s much safer (and more readable) to use named arguments: expense.copy(amountCents = 450). This is especially important because copy() is designed precisely for targeted changes.
Mistake #4: trying to “treat” everything with immutability and copy(), even where direct change is simpler.
Sometimes an object is truly local, short-lived, and not passed to anyone—for example, when you are collecting input data before creating a model. In those places, var can be a perfectly fine tool. The mistake is turning copy() into a religion. It’s just a style that helps where data is shared across parts of the program.
Mistake #5: forgetting that immutability is about an agreement, not magic.
If you store val tags: List<String>, it protects you from tags.add(...) at the compiler level. But if somewhere you still passed a reference to a mutable list into that List, and someone keeps a separate reference to it, changes are still possible. Copying collections with standard functions is also usually shallow: elements remain the same objects. So “immutability” in application code is also a habit of carefully handling what you expose to the outside.
GO TO FULL VERSION