1. Introduction
If you’re just starting out, it’s very easy to think like this: “I need to compare two values — so a > b is enough, and we’re done.” And yes, sometimes it is enough. But as soon as you get a task like “pick the best element by a rule” or “everywhere in the project consider that ‘better’ means X,” Boolean suddenly becomes too “flat.”
The problem is that Boolean answers only the question “is the first better than the second?” and stays silent in two important cases: what to do if the second is better than the first, and what to do if they are equal. But comparison as an ordering operation must be able to distinguish three outcomes, otherwise you’ll start building cascades of if/else, and the code will look like a quest: “find where the logic is.”
Imagine we have two expenses (we continue our console expense tracker): one for 500 USDT, and another for 500 USDT as well. The question “is the first greater than the second?” yields false, but that doesn’t mean the first is smaller. It’s just… the same. And this “the same” is something we must be able to express explicitly.
2. The comparison contract: (T, T) -> Int
When we say “comparison function,” most often we mean a function of the type:
(T, T) -> Int
That is: it takes two values of the same type and returns an Int. And this Int is not interpreted as “a difference in rubles” and not as “some score on a 100-point scale,” but much more simply: the sign of the result matters.
The rule is:
| Result Int | Meaning |
|---|---|
|
the first is less than the second |
|
equal |
|
the first is greater than the second |
That’s exactly how the result of compareTo() is interpreted for comparable types, and the same is true for the result of Comparator.compare(a, b).
To make this stick, you can imagine a “comparison traffic light”: negative means “left/down,” zero means “level,” positive means “right/up.” The exact value you got (-1 or -500) usually doesn’t matter, unless you’re doing something truly exotic.
A small diagram of how this works when you “pick the best”:
flowchart TD
A["cmp(a, b)"] --> B{"result >= 0 ?"}
B -- yes --> C["a is no worse than b → pick a"]
B -- no --> D["a is worse than b → pick b"]
3. Comparison lambda and Comparator<T>
A two-parameter comparison lambda
After lambdas with one parameter (it), lambdas with two parameters look a bit “stricter”: it won’t work anymore; you’ll have to honestly name both parameters.
Here’s an example: compare strings by length.
fun main() {
val compareByLength: (String, String) -> Int = { a, b ->
a.length.compareTo(b.length)
}
println(compareByLength("go", "kotlin")) // -1 (2 < 6)
println(compareByLength("java", "kotlin")) // -1 (4 < 6)
println(compareByLength("hi", "ok")) // 0 (2 == 2)
}
Notice an important detail: we use compareTo rather than a.length - b.length. Why — we’ll talk about a bit later, when we get to common pitfalls.
Another example: comparing numbers “the usual way.”
fun main() {
val compareInts: (Int, Int) -> Int = { a, b ->
a.compareTo(b)
}
println(compareInts(10, 3)) // 1
println(compareInts(3, 10)) // -1
println(compareInts(7, 7)) // 0
}
This looks almost useless (it seems you could just use >), but the meaning appears when you start passing this rule into other functions: “pick the best by this comparison.”
Comparator<T>: an ordering rule as an object
Now let’s meet Comparator<T>. Philosophically, it’s “a box that contains an ordering rule.” Practically, it’s an object with a method compare(a, b): Int, which follows the same sign-of-result contract.
In Kotlin, you typically create a comparator like this:
import kotlin.Comparator
fun main() {
val lengthComparator: Comparator<String> = Comparator { a, b ->
a.length.compareTo(b.length)
}
println(lengthComparator.compare("a", "bbb")) // -1
println(lengthComparator.compare("go", "hi")) // 0
println(lengthComparator.compare("kotlin", "js")) // 1
}
It’s important not to get confused here: we already know how to store a lambda (String, String) -> Int. So why do we need Comparator<String> at all?
The answer is quite practical: many functions in the standard library (and your future functions) expect exactly Comparator<T> as the “official container for the rule.” Even if inside it just calls your lambda, it’s a standard shape that’s easy to recognize at a glance. Plus, a comparator has a clear method name — compare.
At the same time, the logic is the same: compare two objects → an Int with a sign.
4. A mini-tool: pick the “best” element
The temptation right now is huge: “Oh! Comparator! That means sorting!” But we’ll leave sorting alone — it’ll have its own day, and there you’ll get to know the standard functions that do it conveniently and nicely.
Today our task is more modest and more useful for understanding: learn to apply a comparator without magic, manually, through a regular loop. That is, we’ll write a function: “give me a comparison rule — and I’ll pick the best element.”
Let’s start with a function for two values:
import kotlin.Comparator
fun <T> bestOf(a: T, b: T, cmp: Comparator<T>): T {
return if (cmp.compare(a, b) >= 0) a else b
}
fun main() {
val byLength = Comparator<String> { x, y -> x.length.compareTo(y.length) }
println(bestOf("go", "kotlin", byLength)) // kotlin
println(bestOf("java", "js", byLength)) // java
}
The meaning of >= 0 is: if a is greater than b or equal to it, we pick a (i.e., “a is no worse than b”). This is a convenient rule so that in case of equality the result is stable and predictable.
Now let’s extend this to a list. We continue our learning CLI project (roughly “expense tracking”), where expenses are stored in a list. Let an expense be a Triple(title, category, amount).
import kotlin.Comparator
fun findBest(
items: List<Triple<String, String, Int>>,
cmp: Comparator<Triple<String, String, Int>>
): Triple<String, String, Int>? {
var best: Triple<String, String, Int>? = null
for (item in items) {
best = if (best == null) item else bestOf(best, item, cmp)
}
return best
}
Note: the result is Triple<...>? because the list can be empty. We honestly reflect that in the type (you’ve already covered null-safety, and this is exactly the case where null is a normal contract).
5. Expense comparator: compare by amount
Now the best part: we build a comparator for expenses. We’ll compare by amount (the third component of the Triple).
First — a straightforward variant without destructuring:
import kotlin.Comparator
fun main() {
val byAmount = Comparator<Triple<String, String, Int>> { a, b ->
val amountA = a.third
val amountB = b.third
amountA.compareTo(amountB)
}
val e1 = Triple("coffee", "food", 250)
val e2 = Triple("book", "education", 900)
println(byAmount.compare(e1, e2)) // -1
}
You can make it a bit more readable if you’re used to destructuring Triple:
import kotlin.Comparator
fun main() {
val byAmount = Comparator<Triple<String, String, Int>> { x, y ->
val (_, _, ax) = x
val (_, _, ay) = y
ax.compareTo(ay)
}
println(
byAmount.compare(
Triple("coffee", "food", 250),
Triple("book", "education", 900)
)
) // -1
}
Yes, it looks a bit “verbose,” but you can clearly see that we’re comparing exactly the amount, not “something inside.”
Now let’s apply it to a list and find the most expensive expense:
import kotlin.Comparator
fun main() {
val expenses = listOf(
Triple("coffee", "food", 250),
Triple("book", "education", 900),
Triple("taxi", "transport", 600),
)
val byAmount = Comparator<Triple<String, String, Int>> { a, b ->
a.third.compareTo(b.third)
}
val best = findBest(expenses, byAmount)
println(best) // (book, education, 900)
}
And here something should click: a comparator is not “about sorting.” It’s about a single rule for “what is better” that you can pass anywhere: into a max search, into selecting a winner, into comparing two candidates.
6. Composite rules: tie-breaker on equality
As soon as you learn to return negative/zero/positive, the next logical question appears: “What if they are equal by the first criterion — what then?”
Answer: introduce a second criterion, but only if the first one produced 0.
This idea is very common: “compare by the primary key first; if equal — by a fallback key.” In Kotlin documentation, a similar approach is shown on strings: first length, then alphabetical order.
Let’s do this for our expenses: first by amount (greater is better), and if the amounts are the same — compare by the title (title) in alphabetical order, so the rule is stable.
import kotlin.Comparator
fun main() {
val cmp = Comparator<Triple<String, String, Int>> { a, b ->
val amountCompare = a.third.compareTo(b.third)
if (amountCompare != 0) return@Comparator amountCompare
// tie-breaker: by title
a.first.compareTo(b.first)
}
val e1 = Triple("apple", "food", 500)
val e2 = Triple("banana", "food", 500)
println(cmp.compare(e1, e2)) // < 0, because "apple" < "banana"
}
Two things are important to notice here.
First, we don’t “glue everything into one formula.” We do the comparison step by step: first one thing, then another.
Second, we introduced a new syntactic construct: return@Comparator. This is a “local return” from the lambda (i.e., we return a value from the lambda itself, not from main). If this syntax looks unfamiliar for now — that’s fine: you can rewrite it without early return, simply using if.
import kotlin.Comparator
fun main() {
val cmp = Comparator<Triple<String, String, Int>> { a, b ->
val amountCompare = a.third.compareTo(b.third)
if (amountCompare != 0) {
amountCompare
} else {
a.first.compareTo(b.first)
}
}
println(
cmp.compare(
Triple("apple", "food", 500),
Triple("banana", "food", 500)
)
) // < 0
}
The meaning is the same: when the amounts are equal, we enable the tie-breaker (the rule for resolving equality).
7. Why you shouldn’t write a - b
When you write a comparator for Int, your hand often reaches for the classic:
a - b
This indeed yields negative/zero/positive… almost always. But there’s a reason why in production people still more often write a.compareTo(b).
It’s about Int overflow. Int in Kotlin is 32-bit. If you subtract “very large” from “very small,” the result can overflow and end up with an unexpected sign. In our learning project, expense amounts won’t be billions (hopefully), but it’s better to form the right habit: compareTo is safer and more readable.
One more subtle point: comparison must be stable. This means that for identical inputs you always return 0, and for different ones, the sign of the result must not “dance” depending on the compiler’s mood and the phase of the moon.
If a comparator today says that a > b, and tomorrow with the same values it says a < b, any “pick the best” logic will start behaving like a random number generator — just without the fun and with bug reports.
8. Common mistakes when working with (T, T) -> Int and Comparator
Mistake #1: returning Boolean instead of Int.
A very common habit: “comparison = a > b”. But a comparator must distinguish three cases, not two. If you return Boolean, you lose information about equality, and any function that expects the “less/equal/greater” contract becomes impossible or turns into a mess of extra conditions.
Mistake #2: confusing the meaning of the sign and trying to return “pretty numbers.”
Sometimes a beginner starts thinking they must return a “difference” or a “score”: 100, 50, -999. Formally this isn’t forbidden, but it’s almost never needed. A good comparator returns a value where the sign matters, not the magnitude. The most readable option is a.compareTo(b) or a clear combination of multiple compareTo calls with a check for 0.
Mistake #3: writing a.third - b.third and not thinking about overflow.
With small numbers it works, and then one day a weird case appears where everything is “somehow reversed.” It’s much more reliable to write a.third.compareTo(b.third). It’s both safer and immediately reads as “compare.”
Mistake #4: forgetting to handle equality and getting a “random rule.”
If you compare by amount, and the amounts are equal, and at the same time you always return 1 or always -1 “just to have something,” you create an unstable order. As a result, choosing the “best” becomes unpredictable: for equal amounts the rule must return 0 or enable a second criterion (tie-breaker), otherwise the logic starts to “jitter.”
Mistake #5: making the comparison too complex right where it’s used.
If in one place in the code you have a 12-line comparison lambda with a bunch of ifs, reading becomes painful. Usually it’s better to extract the comparator into a val with a proper name (for example, byAmountThenTitle) or into a separate function, so the usage site looks like a “speaking formula,” not like a mini-novel.
Mistake #6: confusing “comparison” and “selecting a winner.”
A comparator answers “who is less/greater/equal,” while selecting a winner is already a decision: who to return if the result is >= 0, and who to return if it is < 0. If you mix these two things in one place, the comparator starts to contain business logic, and then you wonder why the rule can’t be reused in another context.
Mistake #7: not noticing that a comparator is a shared contract and breaking project predictability.
If in one place “better” means “greater amount,” and in another place “better” means “smaller amount” (because there you accidentally swapped a and b), the project starts behaving as if it was written by two different people who never talked to each other. That’s why it’s worth naming comparators so that the direction is visible from the name: byAmountAscending / byAmountDescending (or at least put a comment nearby).
GO TO FULL VERSION