1. Refreshing destructuring
If you’ve ever looked at an object and thought, “I just need to quickly pull two fields out of it—and that’s it,” then you’re already emotionally ready for destructuring.
Destructuring is Kotlin syntax that lets you “take apart” an object into multiple variables in a single line. But this convenience has a price: readability depends on names and context, and field order suddenly becomes important.
Let’s start with a simple example based on our console “expense tracker” (we’ve been evolving it since the moment we moved from Pair to proper models).
data class Expense(val title: String, val category: String, val amountRub: Int)
fun main() {
val e = Expense("Coffee", "Food", 250)
val (title, category, amount) = e
println("Purchase: $title, category: $category, amount: $amount ₣ ")
// Purchase: Coffee, category: Food, amount: 250 ₣
}
Here val (title, category, amount) = e looks almost like “magic.” But magic in Kotlin is usually billed strictly at the rate of “under the hood there are honest functions.” And now we’ll see them.
2. componentN(): what data class generates
For destructuring to work, Kotlin looks for special functions on the object: component1(), component2(), component3(), and so on.
For a data class, the compiler generates these functions automatically—exactly for the primary constructor properties and strictly in the order they are declared. This is part of the standard “auto-generation” bundle for a data class, alongside equals()/hashCode()/toString()/copy().
So for Expense(title, category, amountRub), the logic is roughly: component1() returns title, component2() returns category, component3() returns amountRub.
Let’s look at it “head-on” via direct calls:
data class Expense(val title: String, val category: String, val amountRub: Int)
fun main() {
val e = Expense("Transit fare", "Transport", 60)
println(e.component1()) // Transit fare
println(e.component2()) // Transport
println(e.component3()) // 60
}
What destructuring expands into
When you write a destructuring declaration, Kotlin effectively turns it into a series of componentN() calls. This matters not for pedantry, but so you don’t get surprised by why field order affects the result so much, and why destructuring is not “access by name,” but “access by position.”
Schematically, it looks like this:
flowchart TD
A["val (a, b, c) = obj"] --> B["val a = obj.component1()"]
B --> C["val b = obj.component2()"]
C --> D["val c = obj.component3()"]
That’s exactly why changing the order of properties in a data class is not just “cosmetic,” but a potential change in meaning across all code that used destructuring.
To lock in this relationship, it’s sometimes handy to keep a mini table close by (especially when the class is small but used a lot):
| Position in the primary constructor | Method | What it returns for Expense(title, category, amountRub) |
|---|---|---|
| 1 | |
|
| 2 | |
|
| 3 | |
|
And once again: this is a direct rule of data class generation.
3. Practice: where destructuring is convenient
In tutorial examples, destructuring often looks like a trick—“look what you can do.” In real code, it’s useful in places where you already treat the object as a set of fields: printing, simple aggregation, preparing strings, iterating a collection “and immediately outputting.”
Formatting and output
Suppose we have a list of expenses and want to print them like a small table. You can write e.title, e.category, e.amountRub—and that’s perfectly fine. But sometimes destructuring makes the code shorter without losing meaning.
data class Expense(val title: String, val category: String, val amountRub: Int)
fun printExpense(e: Expense) {
val (title, category, amount) = e
println("$title | $category | $amount ₣ ")
}
fun main() {
val items = listOf(
Expense("Coffee", "Food", 250),
Expense("Metro", "Transport", 60)
)
for (e in items) {
printExpense(e)
}
// Coffee | Food | 250 ₣
// Metro | Transport | 60 ₣
}
Why does it look appropriate here? Because we immediately gave the variables meaningful names, and the function is doing formatting—meaning “extract fields and assemble a string” is its entire job.
_ in destructuring: how to skip what you don’t need
Sometimes you only need to extract one or two values and not the rest. Kotlin lets you “skip” a component using _. This is not just saving keystrokes, but also a way to make the intent obvious: “this part exists, but I’m deliberately not using it.”
Imagine that for a report we only need the amount, and the title and category don’t matter (don’t ask why—accounting sometimes lives by the laws of a parallel universe).
data class Expense(val title: String, val category: String, val amountRub: Int)
fun main() {
val e = Expense("Subscription", "Services", 499)
val (_, _, amount) = e
println("Amount: $amount ₣ ") // Amount: 499 ₣
}
Here _ works as a “variable that won’t exist.” Importantly, Kotlin doesn’t create hidden variables for you with names like unused1: you are explicitly telling the language, “don’t declare anything for this position.”
At the same time, _ works well when the omission is obvious. If you see val (_, _, amount) = e and your brain doesn’t immediately ask, “what was in the first two slots?”, then the context is a good fit. If it does—maybe it’s better to write e.amountRub and not torture the reader.
Destructuring in parameters: lambdas and withIndex()
Destructuring can appear not only in val (a, b) = ..., but also directly in parameters—for example, in lambdas. This is common when working with collections: you iterate over pairs like “index + value” or “key + value,” and you want to get both right away.
A classic example is withIndex(). It produces pairs (conceptually) “index + element,” and Kotlin lets you write a loop with destructuring.
fun main() {
val names = listOf("Ann", "Bob", "Cara")
for ((index, name) in names.withIndex()) {
println("$index -> $name")
}
// 0 -> Ann
// 1 -> Bob
// 2 -> Cara
}
In dry, spec-like terms, parameter destructuring is part of the multi-declarations mechanism: the parameter is considered a single value, but inside you can split it into components if componentN() is available.
Important: in a regular function’s parameters, you must specify the type even if you destructure it into parts.
A small example with an actual function (not a lambda) to feel the difference:
data class Expense(val title: String, val category: String, val amountRub: Int)
fun printShort((title, _, amount): Expense) {
println("$title: $amount ₣ ")
}
fun main() {
printShort(Expense("Coffee", "Food", 250)) // Coffee: 250 ₣
}
This looks impressive, but now the readability question kicks in. For learning—useful. For a real project—use with caution: sometimes it’s simpler and clearer to write fun printShort(e: Expense) and access e.title, e.amountRub.
4. Readability and pitfalls
Destructuring is like hot sauce. A little bit is tasty; a lot and you’re crying, and it no longer matters why. The main criterion is how easy it is for a reader to understand what exactly is stored in each variable without opening the data class declaration.
When destructuring improves code
Good destructuring usually looks like this: variables are named by meaning, several components are used right away, and the object in that place is genuinely perceived as a “set of values.”
data class Expense(val title: String, val category: String, val amountRub: Int)
fun main() {
val (title, category, amountRub) = Expense("Metro", "Transport", 60)
println("$title / $category / $amountRub ₣ ")
// Metro / Transport / 60 ₣
}
Bad destructuring often looks like this: val (a, b, c) = expense. Technically it works, but the meaning disappears. In a 6-line learning task—still tolerable. In code you’ll maintain a month later—that’s already “variable archaeology.”
Another important criterion is “how many fields.” Destructuring 2 values is usually fine (think Pair, withIndex(), Map.Entry). Destructuring 5–7 values is almost always a sign the reader will suffer, and it’s better to access properties by name.
And finally: if you only need one field, destructuring almost never beats expense.amountRub. It’s shorter only in the number of dots, but longer in cognitive load: the reader has to remember the field order.
Field order and refactoring: “quiet” meaning bugs
In Kotlin, componentN() is rigidly tied to the order of properties in the primary constructor of a data class. That means a simple “I’ll reorder fields to make it prettier” can change the meaning of destructuring throughout the project.
Imagine it was like this:
data class Expense(val title: String, val category: String, val amountRub: Int)
And then you decided it’s more logical to keep the amount before the category:
data class Expense(val title: String, val amountRub: Int, val category: String)
If somewhere in the code you had:
val (title, category, amount) = expense
then now category will suddenly become a number, and amount a string…and the compiler will most likely catch it via types.
But it can be worse: if the types match (for example, two String in a row—title and category), the compiler won’t notice, and the meaning will change silently.
That’s one reason to use destructuring where you’re confident: the order is stable, the model is small, and the context is obvious. If the model is “living” and changes often, accessing properties by name is usually safer.
5. Typical mistakes when destructuring a data class
Mistake #1: destructuring for a single variable.
A very common scenario: “I only need amountRub, but I’ll write val (_, _, amount) = e.” Technically this is correct, but the reader has to remember field order, and you have to preserve that order as the model changes. In this situation it’s almost always simpler and clearer to write val amount = e.amountRub.
Mistake #2: variables a, b, c and loss of meaning.
Beginners sometimes get excited that they can write shorter code and end up with val (a, b, c) = expense. This quickly turns code into a riddle: a week later you no longer remember what b is, and the IDE doesn’t always save you, because “go to declaration” takes you to a place where you see b again. Destructuring works well only when names reflect the meaning of the data.
Mistake #3: blind faith in “the order is obvious.”
The field order in a data class feels obvious right up until the first refactor, when someone “just a bit” reordered constructor parameters. Since componentN() is tied to order, not names, destructuring can start meaning something else. This is especially dangerous if several fields have the same type (for example, two String). In such models, it’s safer to access properties by name.
Mistake #4: destructuring in function parameters where simplicity is needed.
Syntax like fun f((a, b): Pair<Int, Int>) or fun printShort((title, _, amount): Expense) looks impressive and even a bit “hacky.” But if the team (or you) isn’t used to this style yet, readability suffers: it’s harder for a person to see “what is the parameter type of the function.” This technique is best kept for short lambdas and very local places, while for public functions it’s more common to choose a regular parameter expense: Expense.
Mistake #5: expecting destructuring to work “for any class.”
Destructuring is possible only if there are componentN() functions. For a data class they’re generated automatically from primary constructor properties, but for a regular class they may not exist. So if you removed data or moved required fields out of the primary constructor, destructuring may suddenly stop compiling—and that’s normal, expected behavior.
GO TO FULL VERSION