if as an Expression

In most C-family languages, if is purely a control-flow statement: it picks which block of code runs, but it never hands back a value. Kotlin’s if can do that job too, but it can also be used as an expression — something that evaluates to a value you can assign to a variable, return from a function, or pass straight into another expression. This one feature is why Kotlin has no ternary operator (condition ? a : b) at all: plain if/else already covers that need, and reads more clearly while doing it.

Overview / How it works

In Kotlin, if is grammatically an expression, not a statement. When the compiler sees if (condition) A else B in a position where a value is expected (the right-hand side of an assignment, a function’s return value, an argument), it treats the whole construct as producing a value: the value of A if the condition is true, otherwise the value of B. When each branch is a block wrapped in curly braces, the value of that branch is the value of its last expression — exactly like a function body. Any earlier statements in the block (a println, a local val) run for their side effects but don’t contribute to the result.

Because both branches must produce a value for the expression to make sense, the compiler enforces a rule you don’t have in an ordinary if statement: an if-expression must have an else branch. If you omit it, there’s no value to use when the condition is false, so the code simply won’t compile. This is different from using if as a statement (where you just want a side effect, like logging or mutating a variable) — there, an else-less if is perfectly legal because no one is trying to consume a result.

The compiler also has to decide the type of the whole expression. It computes the least upper bound (the closest common supertype) of the two branch types. If both branches produce Int, the expression is Int. If one branch produces String and the other produces Int, the compiler falls back to a much less useful common type such as Any — which compiles, but silently loses the specific type information you probably wanted. If a branch has no meaningful value (for example, it only calls println), its type is Unit, Kotlin’s equivalent of "nothing to report," and that infects the overall type of the expression too. Kotlin’s null-safety rules apply exactly as they do everywhere else: if you declare the target variable as a non-null type, neither branch is allowed to produce null, and the compiler will reject an attempt to squeeze a nullable result into a non-nullable slot.

For readers coming from Java: Java has a separate ternary operator (? :) purely because Java’s if is statement-only. Kotlin unifies both jobs into one keyword, so you never need to remember two different syntaxes for "pick one of two values" versus "run one of two blocks."

Syntax

The simplest form places a single expression on each side:

val result = if (condition) expressionIfTrue else expressionIfFalse

When either branch needs more than one line, wrap it in braces; the block’s last line supplies the value:

val result = if (condition) {
    // statements run for side effects
    valueIfTrue
} else {
    // statements run for side effects
    valueIfFalse
}
Part Meaning
condition Any expression of type Boolean.
expressionIfTrue The value produced when condition is true.
else Mandatory when the result is used as a value; optional when if is used purely as a statement.
expressionIfFalse The value produced when condition is false.
block’s last line When a branch is { ... }, its value is whatever its final expression evaluates to.

Examples

Example 1: picking the larger of two numbers

fun main() {
    val a = 12
    val b = 27
    val max = if (a > b) a else b
    println("The larger number is $max")
}
Output:
The larger number is 27

Here if (a > b) a else b evaluates directly to an Int, exactly as if you had written a ternary expression in another language. There is no separate if statement followed by an assignment inside each branch — the whole construct is the value assigned to max.

Example 2: a chained if-expression for grading

fun main() {
    val score = 82
    val grade = if (score >= 90) {
        "A"
    } else if (score >= 80) {
        "B"
    } else if (score >= 70) {
        "C"
    } else {
        "F"
    }
    println("Score $score => Grade $grade")
}
Output:
Score 82 => Grade B

Each branch is a block, but since every block’s only line is a String literal, that literal is the branch’s value. The compiler checks all four branches (three if/else if branches plus the trailing else), confirms they all produce String, and infers grade: String. Because there’s a final else, every possible score is covered, so the expression is guaranteed to always produce a value.

Example 3: using if-expressions inside a function body

fun describeTemperature(celsius: Int): String {
    return if (celsius <= 0) {
        "freezing"
    } else if (celsius < 15) {
        "cold"
    } else if (celsius < 25) {
        "mild"
    } else {
        "hot"
    }
}

fun main() {
    val readings = listOf(-5, 10, 20, 30)
    for (temp in readings) {
        val description = describeTemperature(temp)
        println("$temp C is $description")
    }
}
Output:
-5 C is freezing
10 C is cold
20 C is mild
30 C is hot

This is a very common idiom: instead of declaring an empty var before the if and reassigning it inside each branch, you return the if-expression directly. There’s exactly one place where the function’s result is produced, so it’s impossible to accidentally forget to set it in one branch, and the variable never needs to be mutable.

How it works step by step

  • The condition is evaluated exactly once, left to right, top to bottom through any chained else if clauses.
  • As soon as a true condition is found, that branch’s statements run in order; every other branch is skipped entirely (no branch bodies run "just in case").
  • If a branch is a block, all of its statements execute for their side effects, and the block’s last expression becomes the value of that branch. A trailing statement that produces no useful value (like a bare println call) makes the branch’s value Unit.
  • The compiler unifies the types of all branches (the least upper bound) to decide the static type of the whole if expression, before your code ever runs.
  • If the expression’s result is used as a value and there is no else, compilation fails immediately — there is no runtime fallback, this is caught entirely at compile time.
  • If the result of the if is discarded (used purely as a statement for its side effects), none of the value-related rules apply, and else becomes optional.

Common Mistakes

Mistake 1: forgetting the else branch on an if-expression

fun main() {
    val a = 5
    val b = 10
    val max = if (a > b) a  // ERROR: 'if' must have an 'else' branch when used as an expression
    println(max)
}

This fails to compile with something like 'if' must have both branches if used as an expression, because there is no value to assign to max when a > b is false. The fix is simply to supply the missing branch:

fun main() {
    val a = 5
    val b = 10
    val max = if (a > b) a else b
    println(max)
}
Output:
10

Mistake 2: branches with different value types silently collapse to Any

fun main() {
    val a = 5
    val b = 10
    val max = if (a > b) {
        println("a wins")
    } else {
        b
    }
    println(max)
}
Output:
b wins
10

Wait — the output above actually prints 10, not b wins: since a > b is false, only the else branch runs. The real problem is invisible in this snippet: the if branch’s last statement is println(...), whose type is Unit, while the else branch’s type is Int. The compiler unifies Unit and Int into their common supertype, Any, so max is typed Any instead of Int — you’d be unable to do arithmetic with it elsewhere without an explicit cast, even though it "compiled fine." The fix is to make sure every branch’s last line is genuinely the value you intend:

fun main() {
    val a = 5
    val b = 10
    val max = if (a > b) {
        println("a wins")
        a
    } else {
        println("b wins")
        b
    }
    println("Max is $max")
}
Output:
b wins
Max is 10

Mistake 3: one branch producing null for a non-nullable target

fun main() {
    val name: String? = null
    val greeting: String = if (name != null) "Hello, $name" else null  // ERROR: null cannot be a value of a non-null type String
    println(greeting)
}

Because greeting is declared as the non-nullable type String, the compiler refuses to let either branch produce null. Notice also that inside the true branch, Kotlin smart-casts name from String? to String after the name != null check, so "Hello, $name" is safe to write without a null check inside that branch. The fix is to give the false branch a real, non-null fallback value:

fun main() {
    val name: String? = null
    val greeting: String = if (name != null) "Hello, $name" else "Hello, stranger"
    println(greeting)
}
Output:
Hello, stranger

Best Practices

  • Prefer val result = if (...) a else b over declaring an uninitialized var and assigning it inside each branch — it keeps the variable read-only and guarantees every path sets it.
  • Make sure the last line of every branch block is the value you actually want returned; a stray println or logging call as the last line silently changes the inferred type.
  • When branch types might differ, explicitly annotate the target variable’s type (val x: Int = if (...) ...) so a mismatch becomes a clear compile error instead of a quiet widening to Any.
  • For a long chain of else if comparisons against a single value, prefer a when expression instead — it reads more clearly and the compiler can check exhaustiveness for you.
  • For simple "use this value, or a default if it’s null" logic, prefer the Elvis operator ?: over a full if-expression; reserve if-expressions for cases with real branching logic.
  • Avoid deeply nesting if-expressions inside other if-expressions; extract the inner logic into a small named function instead for readability.

Practice Exercises

  • Write a function absoluteValue(n: Int): Int that uses an if-expression (not Math.abs or kotlin.math.abs) to return n if it’s non-negative, or -n otherwise. Test it with -7 and expect 7.
  • Write a function classify(n: Int): String that returns "negative", "zero", or "positive" using a chained if-expression, then call it on -3, 0, and 8 and print each result.
  • Write a function describe(n: Int?): String that takes a nullable Int and, using an if-expression with a null check, returns "no value" when n is null, otherwise returns "even" or "odd" based on the number. Hint: you’ll need to nest a second if-expression (or a when) inside the non-null branch.

Summary

  • if in Kotlin can be used as a statement (for side effects) or as an expression (to produce a value) — there is no separate ternary operator.
  • When used as an expression, if must have an else branch, or the code fails to compile.
  • If a branch is a block, its value is the value of its last expression; earlier lines only run for side effects.
  • The compiler infers the expression’s type as the least upper bound of both branches’ types — mismatched branch types can silently widen to Any.
  • Null-safety rules apply fully: neither branch may produce null if the target type is non-nullable, and != null checks smart-cast a nullable variable inside that branch.
  • Prefer if-expressions over pre-declared mutable variables, and switch to when once you have more than two or three branches on the same value.