1. Expense instead of Pair: readability and meaning
When we were just learning collections, it was very natural to store an expense as a Pair<String, Int>: "Coffee" to 300. This is the honest beginner’s path: fast, compact, and your brain does not overheat. But Pair has a “side effect”: a week later you open the code and see it.second, and you get a little internal panic — “is second the amount? the category? the quantity? the stress level?”.
The Expense class solves the problem in a simple way: it gives names. We no longer work with “first” and “second”; we work with title and amount. And the nicest part is that all collection tools (loops, filter, map, sortedByDescending, fold) keep working, but lambdas become easier to understand.
Let’s compare “how it was” and “how it is” in one table:
| Approach | What an element looks like | How the code reads | Typical feelings |
|---|---|---|---|
|
|
|
“hope I did not mix it up...” |
|
|
|
“oh, at least this is human-readable” |
A mini-starter for our app (console expense tracking) will look like this:
class Expense(val title: String, val amount: Int)
2. Creating a collection: mutableListOf<Expense>()
Let’s move to practice. We want to store many expenses, so we need a collection. In this course we use MutableList, because the list must be able to grow and shrink: today you bought coffee — add it; tomorrow you found a mistake — remove it.
An important point that often surprises beginners: you can store a list in a val and still change its contents. val forbids reassigning the reference, but it does not forbid calling methods on a mutable object (for example, add). This is a separate useful habit: keep the reference stable.
Example:
fun main() {
val expenses = mutableListOf<Expense>()
expenses.add(Expense("Coffee", 300))
expenses.add(Expense("Taxi", 1200))
println(expenses.size) // 2
}
Notice: expenses is a val, but .add(...) works fine, because the list’s contents change, not the variable.
3. Printing the expense list
When a list stores numbers, we often printed them as-is. When a list stores objects, printing is formatting. The user (and you two days later) need lines like 1) Coffee — 300.
First, let’s make a simple print function. Important: we are not diving into “pretty printers” yet; we need a simple, clear loop.
class Expense(val title: String, val amount: Int)
fun printExpenses(expenses: List<Expense>) {
if (expenses.isEmpty()) {
println("No expenses yet.")
return
}
for (i in expenses.indices) {
val e = expenses[i]
println("${i + 1}) ${e.title} — ${e.amount}")
}
}
Here we use indices to avoid mistakes with list bounds. If we did 0..expenses.size, we would accidentally step onto index size (and that is already “past the fence”).
Let’s test it:
fun main() {
val expenses = mutableListOf(
Expense("Coffee", 300),
Expense("Taxi", 1200),
)
printExpenses(expenses)
// 1) Coffee — 300
// 2) Taxi — 1200
}
4. Adding an expense
Lists become truly useful when users add something to them. Let’s make an add command that asks for a title and an amount, creates an Expense, and adds it to expenses.
To avoid turning main() into “spaghetti with ketchup”, let’s move amount input into a small function. We already know trim() and toIntOrNull(), so we follow the classic pattern: “read → prepare → parse”.
fun readIntOrNull(prompt: String): Int? {
print(prompt)
return readln().trim().toIntOrNull()
}
Now the add function:
class Expense(val title: String, val amount: Int)
fun addExpense(expenses: MutableList<Expense>) {
print("Title: ")
val title = readln().trim()
val amount = readIntOrNull("Amount: ")
if (title.isBlank() || amount == null || amount <= 0) {
println("Error: you need a non-empty title and an amount > 0.")
return
}
expenses.add(Expense(title, amount))
println("Added: $title — $amount")
}
Pay attention to the style: we do simple checks and an early return. This keeps the code linear and readable.
5. Removing an expense: removeAt() and indices
Removal is a favorite place where beginners meet “index out of bounds”. And usually they meet it suddenly, without warning, at high speed.
We will remove by the number in the list that the user sees (i.e., starting from 1), but removeAt() works with indices (starting from 0). So we convert: number -> index = number - 1, and we must check bounds via indices.
fun removeExpense(expenses: MutableList<Expense>) {
if (expenses.isEmpty()) {
println("Nothing to remove: the list is empty.")
return
}
val number = readIntOrNull("Expense number to remove: ")
if (number == null) {
println("Error: enter a number.")
return
}
val index = number - 1
if (index !in expenses.indices) {
println("Error: there is no expense with number $number.")
return
}
val removed = expenses.removeAt(index)
println("Removed: ${removed.title} — ${removed.amount}")
}
Here is the key idea: a list is a “living organism”. After a removal, elements shift, and that is normal. So if the user remembered that “taxi was number 2”, and then removed item 1, taxi becomes number 1. This is not a bug; it is math.
6. Operations on objects in collections
Now comes the pleasant part. The operations you already used on strings and numbers apply to objects in exactly the same way. The only difference is that in the lambda you usually take a field: it.amount, it.title.
filter + map: large expenses and their titles
The map() and filter() operations are basic collection transformations.
Example: find expenses >= 1000 and get their titles.
fun largeExpenseTitles(expenses: List<Expense>): List<String> {
return expenses
.filter { it.amount >= 1000 }
.map { it.title }
}
Test:
fun main() {
val expenses = listOf(
Expense("Coffee", 300),
Expense("Taxi", 1200),
Expense("Lunch", 900),
)
println(largeExpenseTitles(expenses)) // [Taxi]
}
sortedByDescending: sort by amount
Sorting reads especially well on objects because you explicitly say “sort by amount”:
fun topExpenses(expenses: List<Expense>, n: Int): List<Expense> {
return expenses
.sortedByDescending { it.amount }
.take(n)
}
Test:
fun main() {
val expenses = listOf(
Expense("Coffee", 300),
Expense("Taxi", 1200),
Expense("Laptop stand", 2500),
)
val top2 = topExpenses(expenses, 2)
for (e in top2) {
println("${e.title} — ${e.amount}")
}
// Laptop stand — 2500
// Taxi — 1200
}
fold: compute the grand total
fold is a neat reduction of a collection into a single value. In our case, into the grand total.
fun totalAmount(expenses: List<Expense>): Int {
return expenses.fold(0) { acc, e -> acc + e.amount }
}
Test:
fun main() {
val expenses = listOf(
Expense("Coffee", 300),
Expense("Taxi", 1200),
)
println(totalAmount(expenses)) // 1500
}
To “lock it in”, here is a mini pipeline diagram (when you build a report):
flowchart TD
A[expenses: List⟨Expense⟩] --> B[filter amount >= 1000]
B --> C[sortedByDescending amount]
C --> D["take(3)"]
D --> E[map to 'title — amount']
E --> F[joinToString + print]
7. Mini CLI: add/list/remove/total/top/exit
Now let’s assemble everything into a minimal but complete “expense tracker”. We are not building an ideal architecture; we are building a readable skeleton that shows how a list of objects lives and gets updated.
Command loop
main() with a command loop:
fun main() {
val expenses = mutableListOf<Expense>()
while (true) {
print("> ")
when (readln().trim().lowercase()) {
"add" -> addExpense(expenses)
"list" -> printExpenses(expenses)
"remove" -> removeExpense(expenses)
"total" -> println("Total: ${totalAmount(expenses)}")
"exit" -> return
else -> println("Commands: add, list, remove, total, exit")
}
}
}
Command top: a quick report
And a small bonus — the top command, to feel the joy of a pipeline:
fun printTop(expenses: List<Expense>) {
val top = expenses.sortedByDescending { it.amount }.take(3)
if (top.isEmpty()) {
println("No expenses yet.")
return
}
println("Top 3 expenses:")
for (e in top) {
println("${e.title} — ${e.amount}")
}
}
If you want to plug it into main():
"top" -> printTop(expenses)
Migrating from Pair to Expense without a “big bang”
In practice, migration is rarely “one commit and no pain”. Usually part of the code still lives with the old format, and part with the new one. And that is normal: the main thing is to have a clear bridge between them.
Let’s imagine that somewhere we still have an old collection:
val legacy: MutableList<Pair<String, Int>> = mutableListOf(
"Coffee" to 300,
"Taxi" to 1200
)
Let’s make a migration function:
fun migrate(pairs: List<Pair<String, Int>>): MutableList<Expense> {
return pairs.map { (title, amount) -> Expense(title, amount) }.toMutableList()
}
Here we use pair destructuring (title, amount). After migration, all “domain code” is best written via Expense.
Test:
fun main() {
val legacy = listOf("Coffee" to 300, "Taxi" to 1200)
val expenses = migrate(legacy)
printExpenses(expenses)
// 1) Coffee — 300
// 2) Taxi — 1200
}
If you need to temporarily convert back (for example, some old report still expects Pair), you can do that too, but it is better to treat it as a temporary hack:
fun toPairs(expenses: List<Expense>): List<Pair<String, Int>> {
return expenses.map { it.title to it.amount }
}
A nuance about references: what is stored in MutableList<Expense>
Now a topic people often “float” on, because you cannot see it with your eyes. When a list stores Int, it is simple: those are values. When a list stores objects, inside there are references to objects. That means a list element is not a “copy of the expense”, but a “pointer to the same object”.
To feel it hands-on, let’s take an intentionally mutable class:
class Tag(var name: String)
fun main() {
val tags = mutableListOf(Tag("food"))
val t = tags[0]
t.name = "groceries"
println(tags[0].name) // groceries
}
We changed t.name, and it is immediately visible through tags[0], because t and tags[0] point to the same object.
This is not “scary” or “bad”. It is just the object model. But it implies a practical rule: if you make object properties var, then changing an object in one place can “surface” in another place, because it is still the same object.
8. Typical mistakes
Mistake #1: keep storing domain entities in Pair because “it’s shorter”.
In the short run, Pair really does feel faster. But as soon as the pair has meaning (“expense title” and “expense amount”), first/second starts getting in the way of reading. Moving to Expense(title, amount) usually makes code longer by a couple of characters, but reduces the number of “wait, what is this exactly?” moments dramatically.
Mistake #2: make parallel lists instead of one list of objects.
A common anti-pattern: val titles = mutableListOf<String>() and val amounts = mutableListOf<Int>(), and then you add/remove elements “in sync”. This works exactly until the first bug, after which the lists get out of sync, and you suddenly have “Taxi — 300” and “Coffee — 1200”. One MutableList<Expense> removes this entire class of problems.
Mistake #3: forget about the index offset (number starts at 1, index starts at 0).
The user enters “1”, and you habitually do removeAt(1) and remove the second element. This is the kind of bug that makes people suspect the computer does not respect them. The fix is simple: do index = number - 1 and check index in expenses.indices.
Mistake #4: do not check bounds before removeAt() and index access.
removeAt() is not required to “guess” that the user entered 999. It will honestly throw an exception. In a learning project this looks like “the program crashed and rode off into the sunset”. So checking via indices is not bureaucracy, it is basic kindness to your future self.
Mistake #5: turn filter/map/sortedByDescending chains into unreadable “noodles”.
Collection operations are powerful, and that creates the temptation to write one line that spans half the screen. If you cannot quickly read it yourself a week later, it is better to split it into intermediate vals with meaningful names. It is not “extra code”, it is insurance for your own eyes.
Mistake #6: think that val expenses = mutableListOf(...) forbids modifying the list.
This is a common confusion: val forbids reassigning the variable, but it does not forbid mutating the object it references. For MutableList this means add/remove are allowed, and this is normal Kotlin behavior.