1. Pair/Triple and the result contract
To be honest, almost any useful real-world function is a bit “greedy”: it often wants to return not one value, but several at once. For example, you parse a string and want to return both the result and the string “tail”. Or you compute the minimum and maximum. Or you find coordinates and also compute something from them right away. And this is where the conflict appears: a function in Kotlin always returns a single value.
But, as you can probably guess by now, there’s no tragedy in this conflict: a single returned value can be a container that holds multiple parts. Pair and Triple exist precisely to “bundle together” 2–3 values and return them as one package.
Return a container, not “one number”
Previously, you were used to the idea that a return value is something like Int, String, or Boolean. But Kotlin doesn’t limit you to “primitives”: the return value can also be a Pair or a Triple.
For example, if a function computes the minimum and maximum of two numbers, it can return a Pair<Int, Int>, where by contract “the first is the minimum, the second is the maximum”:
fun minMax(a: Int, b: Int): Pair<Int, Int> {
return if (a < b) a to b else b to a
}
fun main() {
val result = minMax(10, 3)
println(result.first) // 3
println(result.second) // 10
}
Here a to b is creating Pair(a, b) in infix form (that is, “between the values”), and it is literally a call to the to function in the standard library.
From the compiler’s point of view, everything is fair: the function returned one value—an object of type Pair. But for us humans, it’s “two related things in one package”.
Result contract: the key agreement
When you return a Pair or Triple, you’re essentially telling the caller: “I’ll give you a box. It has two/three compartments. And you must know what goes where.” That is the result contract: the agreement about the meaning and order of components.
The most common trap here is this: first/second/third are technical names. They carry no domain meaning. Meaning only comes from context: what you name your variables, where you unpack the result, and how you document the function.
Look at the contrast:
fun minMax(a: Int, b: Int): Pair<Int, Int> = if (a < b) a to b else b to a
fun main() {
val x = minMax(10, 3)
println(x.first) // 3
println(x.second) // 10
}
This code works, but it doesn’t read very well: “What is x.first—the minimum? the start of a range? an x-coordinate?”.
Now the same thing, but with proper names:
fun minMax(a: Int, b: Int): Pair<Int, Int> = if (a < b) a to b else b to a
fun main() {
val (min, max) = minMax(10, 3)
println("min=$min max=$max") // min=3 max=10
}
Technically, it’s the same. But now the result contract is “pulled out” and made explicit: min and max are labeled right at the usage site. It’s almost like a comment—except it’s a comment that compiles.
To make this easier to keep in mind, it helps to imagine the Pair/Triple contract as a tiny table in your head (or as a comment above the function):
| The function returns | first means | second means | third means |
|---|---|---|---|
for a range |
|
|
— |
for min/max |
|
|
— |
for statistics |
|
|
|
And yes, order is part of the contract. If you once decided “first is min”, then you live with that. Not “today min/max, tomorrow max/min because the mood changed”.
Destructuring as documentation at the call site
Many people think destructuring is just “to be shorter”. In reality, in educational and team code it’s often about something else: being clearer.
When you write:
val (min, max) = minMax(10, 3)
you do two things at once. First, you unpack the Pair. Second, you document the contract: first holds the minimum, second holds the maximum.
A useful habit: if you return a Pair/Triple from a function, then at the call site it’s almost always better to destructure immediately. Then throughout the rest of the code you work with normal variables, not “anonymous components”.
2. Practice: parse a string and return 2–3 values
Let’s continue our educational console “mini profile parser”. The idea is simple: the user enters a line like:
"Ada Lovelace, 36" And we want to extract the first name, last name, and age separately.
Step 1: split by comma and return two parts
First, let’s extract a very small but useful function: “split the string by the first comma”.
Contract: first is the left part, second is the right part (both already trim()’d).
fun splitByComma(text: String): Pair<String, String> {
val commaIndex = text.indexOf(',')
if (commaIndex == -1) return text.trim() to ""
val left = text.substring(0, commaIndex).trim()
val right = text.substring(commaIndex + 1).trim()
return left to right
}
fun main() {
val (a, b) = splitByComma(" Ada Lovelace, 36 ")
println("a='$a' b='$b'") // a='Ada Lovelace' b='36'
}
Notice: we don’t overcomplicate things. If there is no comma, we return the right part as an empty string. That’s also a contract—just a “bad input contract”.
Step 2: parse the age and return “value + message”
Sometimes it’s useful to return not only data, but also an explanation of what happened. For example: we parsed the age—great. We didn’t parse it—return null and the text “age is not a number”.
Contract: first is Int? (a number or null), second is a message for the user/log.
fun parseAge(text: String): Pair<Int?, String> {
val trimmed = text.trim()
if (trimmed.isEmpty()) return null to "Age is not specified"
val age = trimmed.toIntOrNull()
if (age == null) return null to "Age must be a number"
if (age < 0) return null to "Age cannot be negative"
return age to "OK"
}
fun main() {
val (age, message) = parseAge(" 36 ")
println("age=$age message='$message'") // age=36 message='OK'
}
An important point about the contract: we agreed that "OK" means success. We could have returned a Boolean instead of a string (for example, Pair<Boolean, Int>), but a string is more convenient for an educational example: we can immediately see “why it didn’t work”.
Step 3: put it all together and return a Triple
Now let’s make a function that returns three results: first name, last name, and age (age may be null if parsing failed).
Contract: first is firstName, second is lastName, third is ageOrNull.
To avoid getting into complex string operations, we’ll look for the last name by a space (the first space splits first and last name). If there is no space, the last name will be an empty string.
fun parseNameAge(line: String): Triple<String, String, Int?> {
val (fullName, ageText) = splitByComma(line)
val spaceIndex = fullName.indexOf(' ')
val firstName = if (spaceIndex == -1) fullName.trim() else fullName.substring(0, spaceIndex).trim()
val lastName = if (spaceIndex == -1) "" else fullName.substring(spaceIndex + 1).trim()
val (age, _) = parseAge(ageText)
return Triple(firstName, lastName, age)
}
fun main() {
val (first, last, age) = parseNameAge("Ada Lovelace, 36")
println("first='$first' last='$last' age=$age") // first='Ada' last='Lovelace' age=36
}
Yes, Triple looks appropriate here: three parts of a single “profile” entity. But note that Triple does not make our contract clear by itself. We made it clear by unpacking the result into first/last/age, not into a/b/c.
Mini diagram: from input to usage
Sometimes it helps to reinforce the overall process. It looks roughly like this:
flowchart TD
A["String input (readln)"] --> B["parseNameAge(line) -> Triple"]
B --> C["val (first, last, age) = ..."]
C --> D["Use first/last/age in logic"]
And this is where Pair/Triple work great: the function returns one object, but you immediately “put it onto shelves” at the call site.
3. When Pair/Triple start to hurt readability
In the beginning, Pair feels like a gift: less code, everything compact. But there’s a point where compactness starts working against you.
A common symptom: you catch yourself writing something like result.first and you no longer remember what’s inside. Or you end up with variables like p, x, t, res, and you start talking to the code like an archaeologist: “I wonder what the author meant by this inscription on the wall”.
There is an even clearer red flag: when you return a Pair/Triple, but you have to write a long comment like “first is…, second is…, third is…”, because otherwise nobody will understand. That means your code is already asking for a “named result” (a separate type where fields are named by meaning).
We won’t introduce our own data types yet—this is a topic for the next big blocks of the course—but it’s worth remembering the idea: Pair/Triple are good as quick containers, not as an official data format.
Rules of good style
When you return 2–3 values via Pair/Triple, try to think not “how do I cram everything into a container”, but “how do I make the contract obvious”.
First, lock in the component order as part of the contract and don’t change it depending on mood. If minMax returns (min, max), then it should always be that way.
Second, almost always destructure the result at the call site and give the components meaningful names. val (min, max) = ... is far more readable than val p = ... and then p.first.
Third, don’t make a Triple a “bag of everything”. Three values must be parts of a single meaning. If you return Triple(userName, taxRate, planetName), that’s not a “contract”—it’s the start of a bad joke.
4. Common mistakes
Mistake #1: a drifting component order.
It’s very easy to once return (max, min) because “that’s how it happened”, and elsewhere expect (min, max). From the outside, both variants look the same: Pair<Int, Int>. The compiler won’t save you. Only discipline will: define the contract once and stick to it everywhere.
Mistake #2: meaningless names during destructuring.
Formally, val (a, b) = minMax(10, 3) works. But a day later you’ll look at a and b as “something first” and “something second”. Destructuring is powerful precisely because you can name the components meaningfully: val (min, max) = ....
Mistake #3: Pair/Triple used for unrelated data.
Pair is not a “two-seat cart for anything whatsoever”, but a container for a related result. If the values are not parts of a single meaning, reading the code becomes guesswork, and any change becomes a risk of swapping components.
Mistake #4: returning a Triple when you only need one part.
Sometimes beginners create a “universal result” for all situations, and then in 80% of places they only use first. This is a signal that the result design doesn’t match real usage. Either return exactly what you need, or reconsider the contract.
Mistake #5: trying to “hide” meaning inside first/second/third.
Sometimes it’s tempting to think that first is “the main one”, and second is “secondary”. But for Kotlin, it’s just “first by order” and “second by order”. Don’t attribute magic to the container. The magic should be in your variable names and in a clear function contract.