Kotlin's if Returns a Value, So There's No Ternary

Control flow in full: if and when as expressions, ranges and the modern ..< operator, progressions and how for-loops compile, in/!in membership, while, labelled break/continue, and why loops aren't expressions.

Java draws a hard line between expressions, which produce a value, and statements, which just do something. if is a statement, so to choose a value with it you either reach for the cramped ?: ternary or assign inside both branches. Kotlin erases the line: if is an expression that yields a value, which is exactly why Kotlin has no ternary operator — it would be redundant.

This is the third chapter, picking up from functions. Here’s the everyday control flow you write inside them, run against Kotlin 2.4.10.

if is an expression

Used as a statement, if looks like Java:

if (score > 50) println("pass") else println("fail")

But it also returns the value of whichever branch runs, so you can assign it:

val grade = if (score > 50) "pass" else "fail"

The value of a branch is its last expression, so the braced form works the same way: the last line is the result:

val message = if (score > 90) {
    "excellent"
} else if (score > 50) {
    "pass"
} else {
    "fail"
}

That’s why there’s no score > 50 ? "pass" : "fail" in Kotlin — the plain if already does the ternary’s job and reads better doing it. One rule falls out of this: an if used as an expression must have an else, because otherwise the compiler can’t say what the value is when the condition is false. As a statement (result discarded), the else stays optional.

Ranges and the ..< operator

Before loops, meet the range — a first-class value describing a span. You build a closed range, both ends included, with ..:

val digits = 0..9        // 0 through 9 inclusive

For the far more common half-open case — “up to but not including” — Kotlin has a dedicated operator, ..<:

for (i in 0..<size) { /* 0 to size-1, the array-bounds shape */ }

..< is the modern spelling and the one to prefer; it reads as “up to, exclusive,” and it’s symmetric with how array indices work. You’ll still see the older infix until in existing code — 0 until size is exactly equivalent — but new code should reach for ..<. Both produce the same IntRange. The other variants round out the set:

10 downTo 1              // counts downward
1..10 step 2            // 1, 3, 5, 7, 9
('a'..'z')              // ranges work on any Comparable, not just Int

There’s a type distinction hiding here worth knowing. A plain 0..<5 is an IntRange. The moment you add step or downTo, you get an IntProgression — a start, an end, and a stride. Both are iterable, and both are cheap: iterating for (i in 0..<n) does not allocate an iterator or box the integers. The compiler lowers it to a counting loop over a JVM int, so a range-based for is exactly as fast as the C-style loop it replaces. You get the readable form and the primitive performance, the same bargain as the number types.

in checks membership

Ranges answer membership questions with in, which reads like English and beats a pair of comparisons:

if (age in 18..65) { /* ... */ }
if (c in 'a'..'z') { /* ... */ }

in isn’t limited to ranges; it checks membership in any collection, collapsing a chain of || into one line, and !in negates it:

if (command !in validCommands) {       // validCommands is a List or Set
    return error("unknown command")
}

For a range, x in a..b compiles down to the two comparisons you’d have written by hand, so it’s free; for a Set, it’s a hash lookup. Reach for it whenever you’d otherwise write x >= a && x <= b.

for loops over anything iterable

There’s no C-style for (int i = 0; i < n; i++) in Kotlin. A for walks over anything iterable, and a range is iterable:

for (i in 1..5) println(i)              // 1 2 3 4 5
for (i in 10 downTo 1 step 2) println(i)

The same loop walks a collection directly, no index needed:

for (name in names) println(name)

When you genuinely need the index alongside the element, ask for it with withIndex and destructure, rather than tracking a counter by hand:

for ((i, name) in names.withIndex()) {
    println("$i: $name")
}

And when you want the index by itself — to walk two lists in lockstep, say — loop over the valid positions. names.indices is the range of legal indices, and it reads better than 0..<names.size even though they’re the same thing:

for (i in names.indices) println(names[i])

Reach for these only when the position matters. Most loops want the elements, and the direct for (x in xs) is the one to default to.

while and do-while

The one place Kotlin keeps Java’s shape exactly, because there was nothing to improve:

while (queue.isNotEmpty()) {
    process(queue.removeFirst())
}

do {
    val line = readLine()
    // ...
} while (line != null)

For a fixed count with no index, repeat(n) { } is the idiomatic tool rather than a while and a counter; it’s a standard-library function taking a lambda, and it hands the block the current iteration if you want it.

break and continue, with labels

break and continue work as you’d expect. The addition is labels, for breaking out of an outer loop from an inner one — the situation that pushes Java toward a flag variable or an early return:

outer@ for (row in grid) {
    for (cell in row) {
        if (cell.isMine) break@outer     // exits both loops
    }
}

A label is a name followed by @, attached to the loop, and break@outer / continue@outer target it. You’ll reach for this rarely, and when you do it’s far clearer than the alternatives. (The same label syntax also controls returns out of lambdas — a return@forEach — which the inline-functions chapter covers, because there the mechanism has real subtlety.)

Loops don’t return values

One asymmetry to keep straight: if and when are expressions, but for and while are not. A loop does work; it doesn’t hand back a value, so there’s no val x = for (...). When you want to turn iteration into a result — a sum, a filtered list, a first match — that’s the job of the collection functions (map, filter, sumOf, first), not the loop. Kotlin nudges you toward those precisely because they are expressions and compose, which is the collections chapter’s subject.

A worked example

Put the pieces together — if as an expression, a range check, a for over a collection — and the logic stays flat, with no temporary flags propped up just to be read two lines down:

fun summarize(scores: List<Int>): String {
    var passed = 0
    for (score in scores) {
        val band = if (score >= 90) "A" else if (score >= 50) "pass" else "fail"
        if (band != "fail") passed++
    }
    return if (passed == scores.size) "all clear" else "$passed of ${scores.size} passed"
}

That inner if-ladder is the honest way to write it with the tools so far. Once you’ve met when, the three-way band becomes a cleaner expression still — which is exactly where we’re headed.

Final thoughts

Making if an expression isn’t cosmetic. It removes the ternary, shrinks the gap between “decide a value” and “do a thing,” and sets up the pattern you’ll see everywhere in Kotlin: constructs return values, so you compose them instead of assigning into pre-declared variables. The ranges are the other quiet win — first-class spans that read like English in an in check and compile to a bare counting loop in a for, so the readable form costs nothing.

The same expression idea powers the construct you’ll reach for the moment a choice has more than two branches: when, which replaces switch, long if/else ladders, and instanceof in one move.

Practice: reinforce this with the companion workbook — short, click-to-reveal problems.

Comments