Ranges & Progressions #

Kotlin has a very expressive way to represent value ranges: 1..10, 'a'..'z', "apple".."mango". This isn’t just syntactic sugar — a range is a real data type in Kotlin that can be iterated, checked for membership, and combined with various operators. Behind the scenes, there are two distinct concepts: range (a span of values) and progression (a sequence of values with a specific step). Understanding both opens up a more declarative way of thinking — instead of writing for (i = 0; i < n; i++), you just write for (i in 0 until n). This article covers the entire range and progression ecosystem in Kotlin, from basic usage to custom progressions and idiomatic patterns that make code cleaner.

Range vs Progression #

Before diving into details, it’s important to understand the fundamental difference between the two.

flowchart TD
    A["Range"] --> B["A span between two values\n(start and endInclusive)\nExample: 1..10"]
    A --> C["Membership can be checked\n5 in 1..10 → true"]
    A --> D["Iterable if the type\nsupports it (Int, Long, Char)"]

    E["Progression"] --> F["A sequence of values with a step\n(start, end, step)\nExample: 1..10 step 2"]
    E --> G["Always iterable\n→ 1, 3, 5, 7, 9"]
    E --> H["A subclass of Range\nwith step information"]
RangeProgression
DefinitionA span between two valuesA sequence of values with a step
Example1..101..10 step 2
IterableOnly certain typesAlways
Membershipin / !inin / !in
TypeIntRange, CharRange, etc.IntProgression, etc.

Creating Ranges #

The .. Operator — Inclusive Ranges #

The .. operator creates a range that includes both endpoints (inclusive on both sides).

val angka = 1..10          // 1, 2, 3, ..., 10 (10 included)
val huruf = 'a'..'z'       // 'a', 'b', ..., 'z'
val teks = "apple".."mango" // String range (only for comparison, not iterable)

// Membership checks
println(5 in 1..10)        // true
println(11 in 1..10)       // false
println('e' in 'a'..'z')   // true

// The resulting types
val r: IntRange = 1..10
val c: CharRange = 'a'..'z'
val l: LongRange = 1L..1000L

until — End-Exclusive Ranges #

until creates a range that doesn’t include the end value. This is very common when working with array or list indices, because valid indices are 0 through size - 1.

val daftar = listOf("apel", "jeruk", "mangga", "durian")

// ANTI-PATTERN: using .. with size - 1, easy to get wrong
for (i in 0..daftar.size - 1) {
    println(daftar[i])
}

// CORRECT: until is clearer and safer
for (i in 0 until daftar.size) {
    println(daftar[i])
}

// Or even more idiomatic: use indices
for (i in daftar.indices) {
    println("$i: ${daftar[i]}")
}

// until in common operations
val batasEksklusif = 0 until 100   // 0, 1, ..., 99 (100 not included)
println(99 in batasEksklusif)       // true
println(100 in batasEksklusif)      // false
0 until n is equivalent to 0..n-1, but far safer — no underflow risk when n = 0. With 0..n-1 when n = 0, you get 0..-1 which produces an intuitively empty range. Always use until for index-based ranges.

downTo — Descending Ranges #

downTo creates a range that runs from a large value to a small value. A regular range (..) can’t be iterated backwards — 10..1 is a valid range but empty when iterated.

// ANTI-PATTERN: 10..1 can't be iterated (an empty range when looped)
for (i in 10..1) {
    println(i)  // never executed!
}

// CORRECT: use downTo for backwards iteration
for (i in 10 downTo 1) {
    println(i)  // 10, 9, 8, ..., 1
}

// There's no downTo with until — use downTo + manual stop
// or: (1..10).reversed()
val mundur = (1..10).reversed()   // [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

// Countdown
for (i in 5 downTo 1) {
    println("$i...")
}
println("Start!")

Progressions with step #

step turns a range into a progression with a custom step — not one by one, but jumping a certain number of values.

// Even numbers from 0 to 20
for (i in 0..20 step 2) {
    print("$i ")   // 0 2 4 6 8 10 12 14 16 18 20
}

// Odd numbers
for (i in 1..19 step 2) {
    print("$i ")   // 1 3 5 7 9 11 13 15 17 19
}

// Combining downTo + step
for (i in 100 downTo 0 step 10) {
    print("$i ")   // 100 90 80 70 60 50 40 30 20 10 0
}

// step on a Char range
for (c in 'a'..'z' step 2) {
    print("$c ")   // a c e g i k m o q s u w y
}

// step must be positive — no negatives
// for going backwards with a step: downTo + step
for (i in 20 downTo 0 step 5) {
    print("$i ")   // 20 15 10 5 0
}
flowchart LR
    A["1..10"] -->|"step 1 (default)"| B["1 2 3 4 5 6 7 8 9 10"]
    A -->|"step 2"| C["1 3 5 7 9"]
    A -->|"step 3"| D["1 4 7 10"]
    E["10 downTo 1"] -->|"step 1"| F["10 9 8 7 6 5 4 3 2 1"]
    E -->|"step 2"| G["10 8 6 4 2"]

Ranges in Loops #

This is the most commonly seen use of ranges — as iteration control in for loops.

Standard Iteration #

// Simple loops
for (i in 1..5) print("$i ")          // 1 2 3 4 5
for (i in 1 until 5) print("$i ")     // 1 2 3 4
for (i in 5 downTo 1) print("$i ")    // 5 4 3 2 1

// Loops with an index on a collection
val buah = listOf("apel", "jeruk", "mangga")

// ANTI-PATTERN: manual indexing loop
for (i in 0 until buah.size) {
    println("$i: ${buah[i]}")
}

// CORRECT: use indices or withIndex
for (i in buah.indices) {
    println("$i: ${buah[i]}")
}

for ((index, nama) in buah.withIndex()) {
    println("$index: $nama")
}

repeat — An Alternative for Simple Loops #

When you only need to repeat something N times without caring about the index, repeat is more expressive than for (i in 0 until n).

// ANTI-PATTERN: a for loop with an unused variable
for (i in 0 until 5) {
    println("Hello!")
}

// CORRECT: repeat makes the intent clearer
repeat(5) {
    println("Hello!")
}

// repeat with an index if needed
repeat(5) { i ->
    println("Iteration $i")
}

forEachIndexed vs Range Loops #

val produk = listOf("Laptop", "Mouse", "Keyboard", "Monitor")

// A range loop with an index
for (i in produk.indices) {
    println("${i + 1}. ${produk[i]}")
}

// forEachIndexed: more idiomatic for collections
produk.forEachIndexed { index, nama ->
    println("${index + 1}. $nama")
}

// Both are equivalent — choose the clearer one for the context
// forEachIndexed suits functional contexts
// range loops suit when you need flow control (break, continue)
forEach and forEachIndexed don’t support break or continue — they use lambdas. If you need to stop a loop mid-way or skip specific iterations, use a regular for loop with a range, or use first { }, find { }, or any { } as appropriate.

Ranges in when #

Ranges can be used as conditions in a when expression — one of the features that makes Kotlin’s when far more expressive than Java’s switch.

// Grade classification
fun klasifikasiNilai(nilai: Int): String = when (nilai) {
    in 90..100 -> "A — Excellent"
    in 80 until 90 -> "B — Good"
    in 70 until 80 -> "C — Fair"
    in 60 until 70 -> "D — Poor"
    in 0 until 60 -> "E — Fail"
    else -> "Invalid score"
}

// BMI classifier
fun kategoriBMI(bmi: Double): String = when {
    bmi < 18.5 -> "Underweight"
    bmi in 18.5..24.9 -> "Normal"
    bmi in 25.0..29.9 -> "Overweight"
    bmi >= 30.0 -> "Obese"
    else -> "Invalid"
}

// Age categories
fun kategoriUsia(usia: Int): String = when (usia) {
    in 0..12 -> "Child"
    in 13..17 -> "Teenager"
    in 18..25 -> "Young Adult"
    in 26..59 -> "Adult"
    in 60..Int.MAX_VALUE -> "Senior"
    else -> "Invalid"
}

// Discounts based on purchase quantity
fun hitungDiskon(jumlah: Int): Double = when (jumlah) {
    in 1..4 -> 0.0
    in 5..9 -> 0.05
    in 10..19 -> 0.10
    in 20..49 -> 0.15
    in 50..Int.MAX_VALUE -> 0.20
    else -> 0.0
}

Ranges for Validation #

One of the most practical range uses is input validation — far cleaner than combining >= and <=.

// ANTI-PATTERN: validation with explicit comparisons
fun validasiUsia(usia: Int): Boolean {
    return usia >= 0 && usia <= 150
}

fun validasiSuhu(suhu: Double): Boolean {
    return suhu >= -273.15 && suhu <= 1000.0
}

// CORRECT: use ranges for validation
fun validasiUsia(usia: Int): Boolean = usia in 0..150
fun validasiSuhu(suhu: Double): Boolean = suhu in -273.15..1000.0

// Validation with !in for invalid cases
fun validasiPort(port: Int): Boolean = port in 1..65535
fun isPortInvalid(port: Int): Boolean = port !in 1..65535

// Character validation
fun isHuruf(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z'
fun isAngka(c: Char): Boolean = c in '0'..'9'
fun isAlphanumeric(c: Char): Boolean = isHuruf(c) || isAngka(c)

// Richer validation functions
data class RentangValid(val min: Int, val max: Int) {
    val range = min..max
    fun valid(nilai: Int) = nilai in range
    fun pesanError(nilai: Int) = "Value $nilai must be between $min and $max"
}

val rentangUsia = RentangValid(0, 120)
val rentangPort = RentangValid(1, 65535)

fun prosesInput(usia: Int, port: Int) {
    require(rentangUsia.valid(usia)) { rentangUsia.pesanError(usia) }
    require(rentangPort.valid(port)) { rentangPort.pesanError(port) }
    // continue processing...
}

Ranges on Non-Numeric Types #

Ranges aren’t limited to numbers. Kotlin supports ranges on Char and String (for comparison), as well as any type implementing Comparable.

Char Ranges #

// Iterating letters
for (c in 'A'..'Z') {
    print(c)   // ABCDEFGHIJKLMNOPQRSTUVWXYZ
}

// Alphabet generators
val hurufKecil = ('a'..'z').toList()
// ['a', 'b', 'c', ..., 'z']

val hurufBesar = ('A'..'Z').toList()
// ['A', 'B', 'C', ..., 'Z']

// Digits as Chars
val digitChar = ('0'..'9').toList()
// ['0', '1', '2', ..., '9']

// A simple password generator
val karakter = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun buatPasswordAcak(panjang: Int): String {
    return (1..panjang)
        .map { karakter.random() }
        .joinToString("")
}

// Character validation with ranges
fun isVokal(c: Char): Boolean = c.lowercaseChar() in "aeiou"  // a trick with String
fun isKonsonan(c: Char): Boolean = c in 'a'..'z' && !isVokal(c)

String Ranges and Comparables #

// String ranges: membership can be checked but not iterated
val rentangBuah = "apel".."mangga"
println("jeruk" in rentangBuah)    // true (lexicographic comparison)
println("semangka" in rentangBuah) // false ('s' > 'm')

// Comparable ranges: any type implementing Comparable
data class Versi(val major: Int, val minor: Int) : Comparable<Versi> {
    override fun compareTo(other: Versi): Int {
        return if (major != other.major) major - other.major
        else minor - other.minor
    }
}

val versiDidukung = Versi(2, 0)..Versi(4, 9)
println(Versi(3, 5) in versiDidukung)  // true
println(Versi(1, 9) in versiDidukung)  // false
println(Versi(5, 0) in versiDidukung)  // false

// Dates with LocalDate (kotlinx-datetime)
// val rentangLiburan = LocalDate(2024, 12, 24)..LocalDate(2025, 1, 1)
// val hariIni = LocalDate.now()
// val sedangLibur = hariIni in rentangLiburan

Operations on Ranges and Progressions #

Ranges and progressions have several useful utility functions.

val range = 1..20

// Conversion to a List
val list = range.toList()           // [1, 2, 3, ..., 20]
val listStep = (1..20 step 3).toList()  // [1, 4, 7, 10, 13, 16, 19]

// Range properties
println(range.first)    // 1
println(range.last)     // 20
println(range.step)     // 1 (IntProgression)

val prog = 1..20 step 3
println(prog.first)     // 1
println(prog.last)      // 19 (not 20, because 20 isn't in the progression)
println(prog.step)      // 3

// isEmpty: reversed ranges are always empty
println((5..1).isEmpty())           // true
println((1..5).isEmpty())           // false

// contains: the same as `in`
println(range.contains(10))         // true
println(10 in range)                // true (equivalent)

// reversed
val rangeReversed = (1..10).reversed()   // [10, 9, ..., 1]

// sum, average, count on progressions
val jumlah = (1..100).sum()             // 5050
val rata = (1..10).average()            // 5.5
val banyak = (1..20 step 2).count()     // 10

// any, all, none
val adaYangBesar = (1..100).any { it > 90 }    // true
val semuaPositif = (1..100).all { it > 0 }     // true
val tidakAdaNol = (1..100).none { it == 0 }    // true

Custom Progressions #

Kotlin lets you create your own progression types for custom types by implementing Iterable and the rangeTo operator.

// Example: a progression for a simple date
data class Tanggal(val hari: Int) : Comparable<Tanggal> {
    override fun compareTo(other: Tanggal) = hari - other.hari

    operator fun plus(n: Int) = Tanggal(hari + n)
}

class TanggalProgression(
    override val start: Tanggal,
    override val endInclusive: Tanggal,
    val langkah: Int = 1
) : Iterable<Tanggal>, ClosedRange<Tanggal> {

    override fun iterator(): Iterator<Tanggal> = object : Iterator<Tanggal> {
        var current = start
        override fun hasNext() = current <= endInclusive
        override fun next(): Tanggal {
            val result = current
            current = current + langkah
            return result
        }
    }
}

// The rangeTo operator for Tanggal
operator fun Tanggal.rangeTo(lain: Tanggal) = TanggalProgression(this, lain)

// An infix extension for step
infix fun TanggalProgression.langkah(n: Int) =
    TanggalProgression(start, endInclusive, n)

// Usage
val awal = Tanggal(1)
val akhir = Tanggal(31)

for (tgl in awal..akhir) {
    println("Day ${tgl.hari}")
}

for (tgl in awal..akhir langkah 7) {
    println("Week ${(tgl.hari - 1) / 7 + 1}: day ${tgl.hari}")
}

Idiomatic Patterns #

A few summarized range usage patterns frequently appearing in production code.

Sampling and Data Splitting #

val data = (1..1000).toList()

// Take a sample of every N elements
val sampel = data.filterIndexed { index, _ -> index % 10 == 0 }
// [1, 11, 21, 31, ..., 991]  (every 10th element)

// Split data into batches
val batch = data.chunked(100)
// [[1..100], [101..200], ..., [901..1000]]

// Take the first 10% and the last 10%
val awal10persen = data.take(data.size / 10)
val akhir10persen = data.takeLast(data.size / 10)

Fibonacci with Ranges #

// A Fibonacci generator using a progression
fun fibonacci(n: Int): List<Long> {
    if (n <= 0) return emptyList()
    if (n == 1) return listOf(1L)

    val hasil = mutableListOf(1L, 1L)
    for (i in 2 until n) {
        hasil.add(hasil[i - 1] + hasil[i - 2])
    }
    return hasil
}

println(fibonacci(10))
// [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

Clamping — Limiting a Value Within a Range #

// Clamp: make sure a value is within bounds
fun Int.coerceIn(min: Int, max: Int) = when {
    this < min -> min
    this > max -> max
    else -> this
}

// Kotlin already has a built-in coerceIn!
val nilai = 150
val dibatasi = nilai.coerceIn(0, 100)    // 100
val normal = 75.coerceIn(0, 100)         // 75
val negatif = (-5).coerceIn(0, 100)      // 0

// coerceIn also accepts a range directly
val range = 0..100
val hasilClamp = nilai.coerceIn(range)   // 100

// coerceAtLeast and coerceAtMost
val minSaja = (-5).coerceAtLeast(0)      // 0
val maxSaja = 150.coerceAtMost(100)      // 100

Multiplication Tables #

// Multiplication tables with nested ranges
fun cetakTabelPerkalian(batas: Int = 10) {
    for (i in 1..batas) {
        for (j in 1..batas) {
            print("${(i * j).toString().padStart(4)}")
        }
        println()
    }
}

// A functional version
val tabel = (1..10).map { i ->
    (1..10).map { j -> i * j }
}

Comparison with the Java Approach #

Kotlin ranges are far more expressive than conventional Java loops.

// Java style (valid in Kotlin but not idiomatic)
// ANTI-PATTERN:
var i = 0
while (i < 10) {
    println(i)
    i++
}

// ANTI-PATTERN:
for (i in 0..9) {      // but the intent is 0 to 9
    println(i)
}

// CORRECT — idiomatic Kotlin:
for (i in 0 until 10) {    // clear: 0 up to and excluding 10
    println(i)
}

repeat(10) { i ->           // if it's just iteration without complex logic
    println(i)
}

// Range-based conditions — Java needs verbose &&
// ANTI-PATTERN:
if (nilai >= 80 && nilai <= 100) println("Good")

// CORRECT:
if (nilai in 80..100) println("Good")
flowchart TD
    A{What needs to\nbe done?} --> B["Forward iteration\n1 at a time"]
    A --> C["Backward iteration"]
    A --> D["Iteration with\njumps"]
    A --> E["Membership check\nvalue within a range"]
    A --> F["Value classification\nin when"]
    A --> G["Bound validation\nof values"]

    B --> B1["for (i in start..end)\nor\nfor (i in start until end)"]
    C --> C1["for (i in end downTo start)"]
    D --> D1["for (i in start..end step n)\nor\nfor (i in end downTo start step n)"]
    E --> E1["nilai in start..end\nnilai !in start..end"]
    F --> F1["when (x) { in a..b -> ... }"]
    G --> G1["nilai.coerceIn(min, max)\nrequire(nilai in min..max)"]

Summary #

  • .. creates an inclusive range on both ends (1..10 → 1 through 10 included). until is end-exclusive (0 until 10 → 0 through 9). Use until for index-based ranges — safer than 0..size-1.
  • downTo for backward iteration (10 downTo 1). A regular range 10..1 doesn’t produce an error but produces an empty range when iterated — a common trap.
  • step turns a range into a progression with a custom step (1..20 step 3 → 1, 4, 7, …, 19). Can be combined with downTo.
  • in and !in for membership checks. Far cleaner than x >= a && x <= b — use these for validation and conditions.
  • when + ranges is a very expressive replacement for switch-case value classification: in 80..100 -> "Good".
  • repeat(n) { } for iteration without needing an index — clearer intent than for (i in 0 until n) that never uses i.
  • coerceIn clamps a value within a range; coerceAtLeast and coerceAtMost for one-sided limits. Use these instead of manual if-else clamping.
  • Ranges can be created on any Comparable type — Char, String, or a custom class implementing Comparable. Custom progressions can be created by implementing Iterable and the rangeTo operator.
  • indices on a List/Array is a shortcut for 0 until size — always use this instead of calculating ranges manually.
  • Progressions support collection operations like sum(), average(), count(), any {}, all {}, toList() — no manual conversion to a List needed first.

← Previous: Scope Functions   Next: Higher-Order Functions →

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