Comparator & Sorting #

Sorting is a need that almost always comes up — product lists sorted by price, employees sorted by name, transactions sorted by newest date, and the most complex: data sorted by several criteria at once (department first, then name, then salary). Kotlin provides a very expressive sorting API: sortedBy for simple cases, compareBy for multi-criteria, and full Comparator for maximum control. Understanding the difference between Comparable (an object that knows how to compare itself) and Comparator (a separate object that knows how to compare two other objects) is the key to choosing the right approach. This article covers the entire sorting ecosystem in Kotlin, from the simplest to the most complex, along with idiomatic patterns that make sorting code easy to read and modify.

Basic Sorting #

Kotlin provides four main functions for sorting collections:

FunctionIn-placeResultDescription
sort()✓ YesUnitOnly for MutableList
sortBy { }✓ YesUnitIn-place with a key selector
sorted()✗ NoNew ListFor Comparable types
sortedBy { }✗ NoNew ListWith a key selector
// Types that implement Comparable by default
val angka = mutableListOf(5, 2, 8, 1, 9, 3)
angka.sort()                    // in-place: [1, 2, 3, 5, 8, 9]
angka.sortDescending()          // in-place: [9, 8, 5, 3, 2, 1]

val kata = listOf("jeruk", "apel", "mangga", "durian")
val terurut = kata.sorted()           // New List: [apel, durian, jeruk, mangga]
val terbalik = kata.sortedDescending() // New List: [mangga, jeruk, durian, apel]

// sortedBy — sort by a key selector
data class Produk(val nama: String, val harga: Double, val stok: Int)

val produk = listOf(
    Produk("Laptop", 15_000_000.0, 10),
    Produk("Mouse", 250_000.0, 50),
    Produk("Keyboard", 800_000.0, 30),
    Produk("Monitor", 3_500_000.0, 15),
    Produk("Webcam", 750_000.0, 20)
)

val urutHarga = produk.sortedBy { it.harga }
// [Mouse, Webcam, Keyboard, Monitor, Laptop]

val urutHargaTurun = produk.sortedByDescending { it.harga }
// [Laptop, Monitor, Keyboard, Webcam, Mouse]

val urutNama = produk.sortedBy { it.nama }
// [Keyboard, Laptop, Monitor, Mouse, Webcam]

// ANTI-PATTERN: in-place sort on a read-only list
// produk.sort()  // ERROR — produk is a List (read-only)

// For an in-place sort: you need a MutableList
val produkMutable = produk.toMutableList()
produkMutable.sortBy { it.harga }   // in-place OK

Comparable — Objects That Can Be Compared #

Comparable<T> is an interface that makes an object know how to compare itself to other objects of the same type. Implement this when there’s a clear “natural” order for the type.

// Implementing Comparable
data class Versi(val major: Int, val minor: Int, val patch: Int) : Comparable<Versi> {

    override fun compareTo(other: Versi): Int {
        // compareTo must return:
        // negative  → this < other
        // 0         → this == other
        // positive  → this > other

        if (major != other.major) return major - other.major
        if (minor != other.minor) return minor - other.minor
        return patch - other.patch
    }

    override fun toString() = "$major.$minor.$patch"
}

val versi = listOf(
    Versi(2, 0, 0),
    Versi(1, 9, 5),
    Versi(2, 1, 0),
    Versi(1, 0, 0),
    Versi(2, 0, 1)
)

val terurut = versi.sorted()
// [1.0.0, 1.9.5, 2.0.0, 2.0.1, 2.1.0]

val terbaru = versi.max()   // 2.1.0
val tertua = versi.min()    // 1.0.0

// Comparison operators automatically become available after implementing Comparable
println(Versi(2, 0, 0) > Versi(1, 9, 5))   // true
println(Versi(1, 0, 0) in Versi(1, 0, 0)..Versi(2, 0, 0))  // true

// compareValuesBy — a more concise way to implement compareTo
data class Karyawan(val nama: String, val departemen: String, val gaji: Double)
    : Comparable<Karyawan> {

    override fun compareTo(other: Karyawan): Int =
        compareValuesBy(this, other,
            { it.departemen },   // sort by department first
            { it.nama }          // then by name
        )
}
Implement Comparable only if there’s one clear, unambiguous “natural” order for the type. For versions: the natural order is from smallest to largest. If there are several valid ways to sort (price vs name vs stock), use a separate Comparator rather than forcing one order into the class.

Comparator — Flexible Sorting #

Comparator<T> is a separate object that defines how to compare two objects. This is more flexible than Comparable because you can have many different Comparators for the same type.

compareBy — Multi-Criteria #

data class Karyawan(
    val nama: String,
    val departemen: String,
    val gaji: Double,
    val tahunMasuk: Int
)

val karyawan = listOf(
    Karyawan("Clara", "Engineering", 18_000_000.0, 2020),
    Karyawan("Andi", "Marketing", 12_000_000.0, 2019),
    Karyawan("Budi", "Engineering", 15_000_000.0, 2021),
    Karyawan("Dina", "HR", 10_000_000.0, 2018),
    Karyawan("Eva", "Engineering", 18_000_000.0, 2019),
    Karyawan("Fani", "Marketing", 13_500_000.0, 2020)
)

// compareBy — sort by one criterion
val comparatorNama = compareBy<Karyawan> { it.nama }
val urutNama = karyawan.sortedWith(comparatorNama)
// [Andi, Budi, Clara, Dina, Eva, Fani]

// compareBy with several criteria — priority from left to right
val comparatorDeptNama = compareBy<Karyawan>({ it.departemen }, { it.nama })
val urutDeptNama = karyawan.sortedWith(comparatorDeptNama)
// Engineering: Budi, Clara, Eva
// HR: Dina
// Marketing: Andi, Fani

// compareByDescending — reversed order
val comparatorGajiTurun = compareByDescending<Karyawan> { it.gaji }
val urutGajiTurun = karyawan.sortedWith(comparatorGajiTurun)
// [Clara, Eva, Budi, Fani, Andi, Dina] — highest salary first

thenBy — Layered Sorting #

thenBy and thenByDescending add the next sorting criterion — used when the previous criterion produces equal values (tie-breaking).

// The classic case: department ascending, then salary descending, then name ascending
val comparatorKompleks = compareBy<Karyawan> { it.departemen }
    .thenByDescending { it.gaji }
    .thenBy { it.nama }

val hasilKompleks = karyawan.sortedWith(comparatorKompleks)
// Engineering: Eva(18jt), Clara(18jt), Budi(15jt)   — salary descending, name ascending for ties
// HR: Dina(10jt)
// Marketing: Fani(13.5jt), Andi(12jt)

// Even more levels:
val comparatorLengkap = compareBy<Karyawan>
    { it.departemen }
    .thenByDescending { it.gaji }
    .thenBy { it.tahunMasuk }
    .thenBy { it.nama }

// thenComparator — use another comparator as a tie-breaker
val comparatorGajiNama = compareByDescending<Karyawan> { it.gaji }
    .thenComparator { a, b -> a.nama.compareTo(b.nama) }

Custom Comparators with Lambdas #

// A Comparator as a direct lambda
val sortTidakBiasa = Comparator<String> { a, b ->
    // Sort by length first, then alphabetically
    val panjangCmp = a.length.compareTo(b.length)
    if (panjangCmp != 0) panjangCmp else a.compareTo(b)
}

val kata = listOf("kotlin", "is", "a", "great", "language")
println(kata.sortedWith(sortTidakBiasa))
// [a, is, great, kotlin, language]

// A null-safe comparator — place nulls at the end
val comparatorNullAkhir = compareBy<String?>(nullsLast()) { it }
val denganNull = listOf("banana", null, "apple", null, "cherry")
println(denganNull.sortedWith(comparatorNullAkhir))
// [apple, banana, cherry, null, null]

// Nulls first
val comparatorNullAwal = compareBy<String?>(nullsFirst()) { it }
println(denganNull.sortedWith(comparatorNullAwal))
// [null, null, apple, banana, cherry]

naturalOrder and reverseOrder #

// naturalOrder — the natural order (Comparable)
val naturalComp: Comparator<Int> = naturalOrder()
val reverseComp: Comparator<Int> = reverseOrder()

listOf(3, 1, 4, 1, 5, 9).sortedWith(naturalComp)  // [1, 1, 3, 4, 5, 9]
listOf(3, 1, 4, 1, 5, 9).sortedWith(reverseComp)  // [9, 5, 4, 3, 1, 1]

// reversed() — reverse an existing comparator
val comparatorHarga = compareBy<Produk> { it.harga }
val comparatorHargaTurun = comparatorHarga.reversed()

// Combining with thenBy after reversed
val comparatorFinal = compareByDescending<Produk> { it.harga }
    .thenBy { it.nama }

String Sorting — Important Nuances #

String sorting has several nuances worth understanding, especially for Indonesian applications.

// Default sorting — by Unicode code, case-sensitive
val nama = listOf("Zara", "andi", "Budi", "clara", "Dina")
println(nama.sorted())
// [Budi, Dina, Zara, andi, clara] — uppercase before lowercase!

// Case-insensitive sort
val namaInsensitive = nama.sortedBy { it.lowercase() }
// [andi, Budi, clara, Dina, Zara]

// Locale-aware sort — for Indonesian applications
import java.text.Collator
import java.util.Locale

val collatorID = Collator.getInstance(Locale("id", "ID"))
val comparatorLokal = Comparator<String> { a, b -> collatorID.compare(a, b) }

val namaID = listOf("Żurek", "Ąndré", "Budi", "Żaneta", "andi")
println(namaID.sortedWith(comparatorLokal))
// Order according to Indonesian language rules

// Sort by String length, then alphabetically
val sortPanjang = compareBy<String>({ it.length }, { it })
listOf("cc", "aaa", "b", "dddd", "ee").sortedWith(sortPanjang)
// [b, cc, ee, aaa, dddd]

// Natural sort — sorting strings with numbers naturally
// "item2" should come before "item10" (naturally, not lexicographically)
fun naturalSortComparator(): Comparator<String> = Comparator { a, b ->
    val reNumerik = Regex("(\\D+)|(\\d+)")
    val tokenA = reNumerik.findAll(a).map { it.value }.toList()
    val tokenB = reNumerik.findAll(b).map { it.value }.toList()

    for (i in 0 until minOf(tokenA.size, tokenB.size)) {
        val ta = tokenA[i]
        val tb = tokenB[i]
        val cmp = if (ta.first().isDigit() && tb.first().isDigit()) {
            ta.toLong().compareTo(tb.toLong())
        } else {
            ta.compareTo(tb)
        }
        if (cmp != 0) return@Comparator cmp
    }
    tokenA.size.compareTo(tokenB.size)
}

val file = listOf("item10", "item2", "item1", "item20", "item3")
println(file.sortedWith(naturalSortComparator()))
// [item1, item2, item3, item10, item20] — natural sort!
// not: [item1, item10, item2, item20, item3] — lexicographic

Sorting Nullables #

Sorting collections containing nulls requires special handling.

data class Produk(val nama: String, val diskon: Double?)

val produk = listOf(
    Produk("Laptop", null),
    Produk("Mouse", 0.15),
    Produk("Keyboard", null),
    Produk("Monitor", 0.05),
    Produk("Webcam", 0.20)
)

// ANTI-PATTERN: sortedBy directly will error if there are nulls
// produk.sortedBy { it.diskon }  // Null cannot be compared

// CORRECT: handle nulls explicitly
// Nulls at the end (products without discounts below)
val urutDiskonNullAkhir = produk.sortedWith(
    compareBy(nullsLast()) { it.diskon }
)
// [Monitor(0.05), Mouse(0.15), Webcam(0.20), Laptop(null), Keyboard(null)]

// Nulls at the start
val urutDiskonNullAwal = produk.sortedWith(
    compareBy(nullsFirst()) { it.diskon }
)
// [Laptop(null), Keyboard(null), Monitor(0.05), Mouse(0.15), Webcam(0.20)]

// Nulls with a default value for sorting
val urutDiskonDefault = produk.sortedBy { it.diskon ?: -1.0 }
// Null treated as -1.0: [Laptop, Keyboard, Monitor, Mouse, Webcam]

// Nullable with thenBy — multi-criteria with null handling
val urutLengkap = produk.sortedWith(
    compareBy(nullsLast<Double>()) { it.diskon }
        .thenBy { it.nama }
)

Sort Stability #

Kotlin (since version 1.7 for the JVM, and on all other targets) uses stable sorting — elements with equal keys maintain their relative order from before the sort.

data class Nilai(val nama: String, val skor: Int)

val data = listOf(
    Nilai("Zara", 90),
    Nilai("Andi", 85),
    Nilai("Budi", 90),
    Nilai("Clara", 85),
    Nilai("Dina", 90)
)

// Sort by score only
val hasilUrut = data.sortedBy { it.skor }
// [Andi, Clara, Zara, Budi, Dina]
// — the relative order of Andi-Clara (score 85) is preserved from the original order
// — the relative order of Zara-Budi-Dina (score 90) is preserved from the original order

// Stability matters for UI: sorting by different columns
// If the user sorts by name first, then by score — the name order within equal scores is preserved

Idiomatic Patterns for Real Cases #

Sortable Tables / Data Grids #

enum class ArahUrut { ASC, DESC }
data class KolomUrut(val kolom: String, val arah: ArahUrut)

fun List<Karyawan>.urutkan(kriteria: List<KolomUrut>): List<Karyawan> {
    if (kriteria.isEmpty()) return this

    var comparator: Comparator<Karyawan>? = null

    for (k in kriteria) {
        val comp: Comparator<Karyawan> = when (k.kolom) {
            "nama" -> compareBy { it.nama }
            "departemen" -> compareBy { it.departemen }
            "gaji" -> compareBy { it.gaji }
            "tahunMasuk" -> compareBy { it.tahunMasuk }
            else -> continue
        }

        val compFinal = if (k.arah == ArahUrut.DESC) comp.reversed() else comp
        comparator = comparator?.then(compFinal) ?: compFinal
    }

    return comparator?.let { sortedWith(it) } ?: this
}

// Usage — e.g., from a user request
val kriteria = listOf(
    KolomUrut("departemen", ArahUrut.ASC),
    KolomUrut("gaji", ArahUrut.DESC)
)
val hasilUrut = karyawan.urutkan(kriteria)

Top-N with a Heap (Efficient) #

// For a small N from very large data — more efficient than a full sort
fun <T, R : Comparable<R>> List<T>.topN(n: Int, selector: (T) -> R): List<T> {
    // Use a min-heap with size n
    val heap = java.util.PriorityQueue<T>(n, compareBy(selector))
    for (item in this) {
        if (heap.size < n) {
            heap.offer(item)
        } else if (selector(item) > selector(heap.peek()!!)) {
            heap.poll()
            heap.offer(item)
        }
    }
    return heap.sortedByDescending(selector)
}

// Take the 5 most expensive products from a list of millions
val top5Mahal = semuaProduk.topN(5) { it.harga }

Sorting with Key Caching #

// If the key selector is expensive (parsing, complex calculations) — cache the results
data class Dokumen(val judul: String, val isi: String)

// ANTI-PATTERN: the key selector is called repeatedly during the sort
dokumen.sortedBy { it.isi.split(" ").size }   // counts words on every comparison!

// CORRECT: count once, sort by the cache
dokumen
    .map { doc -> doc to doc.isi.split(" ").size }   // count once
    .sortedBy { (_, jumlahKata) -> jumlahKata }
    .map { (doc, _) -> doc }                          // get the documents back

Decision Tree — Choosing the Right Approach #

flowchart TD
    A{How many sorting\ncriteria?} --> B["One criterion"]
    A --> C["Several criteria"]

    B --> D{Is there a\nnatural order?}
    D -- Yes --> E["Implement Comparable\nin the class, use sorted()"]
    D -- No --> F["sortedBy { selector }\nor sortedByDescending { }"]

    C --> G{Are all criteria\nin the same direction?}
    G -- Yes all ASC --> H["compareBy({ a }, { b }, { c })\n.sortedWith(comparator)"]
    G -- No --> I["compareBy { a }\n.thenByDescending { b }\n.thenBy { c }"]

    A --> J["Any nulls\nin the data?"]
    J --> K["compareBy(nullsLast()) { }\nor compareBy(nullsFirst()) { }"]

Summary #

  • sortedBy { } for sorting by one criterion without nulls — the most commonly used and the most concise. sortedByDescending { } for the reversed order.
  • sortedWith(comparator) for complex sorting — used with compareBy, thenBy, thenByDescending, or a custom Comparator.
  • compareBy({ a }, { b }) for multi-criteria with the same direction (all ascending). compareBy { a }.thenByDescending { b }.thenBy { c } for mixed directions.
  • Comparable fits when there’s one clear “natural” order for the type (Versi, Tanggal, Uang). Implement compareTo and use compareValuesBy to simplify the logic.
  • Comparator is more flexible — create several different comparators for the same type as the context requires (sort by price for the product page, sort by name for the admin list).
  • nullsLast() and nullsFirst() for handling nullables in sorting — always determine the null position explicitly rather than letting a NullPointerException happen.
  • Correct String sorting for Indonesian applications needs Collator.getInstance(Locale("id", "ID")) — the default Unicode sort doesn’t follow Indonesian language rules.
  • Sort stability in Kotlin is guaranteed — elements with equal keys maintain their original relative order. Leverage this for interactive multi-column sorting.
  • Avoid expensive key selectors in sortedBy — the selector function is called repeatedly during comparisons. Use the map-sort-map pattern if the selector requires heavy calculations.
  • sort() in-place can only be called on a MutableListsorted() always produces a new List and can be called on any read-only List.

← Previous: Enum   Next: Random →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact