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
conditionis evaluated exactly once, left to right, top to bottom through any chainedelse ifclauses. - As soon as a
truecondition 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
printlncall) makes the branch’s valueUnit. - The compiler unifies the types of all branches (the least upper bound) to decide the static type of the whole
ifexpression, 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
ifis discarded (used purely as a statement for its side effects), none of the value-related rules apply, andelsebecomes 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 bover declaring an uninitializedvarand 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
printlnor 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 toAny. - For a long chain of
else ifcomparisons against a single value, prefer awhenexpression 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): Intthat uses an if-expression (notMath.absorkotlin.math.abs) to returnnif it’s non-negative, or-notherwise. Test it with-7and expect7. - Write a function
classify(n: Int): Stringthat returns"negative","zero", or"positive"using a chained if-expression, then call it on-3,0, and8and print each result. - Write a function
describe(n: Int?): Stringthat takes a nullableIntand, using an if-expression with a null check, returns"no value"whennisnull, otherwise returns"even"or"odd"based on the number. Hint: you’ll need to nest a second if-expression (or awhen) inside the non-null branch.
Summary
ifin 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,
ifmust have anelsebranch, 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
nullif the target type is non-nullable, and!= nullchecks smart-cast a nullable variable inside that branch. - Prefer
if-expressions over pre-declared mutable variables, and switch towhenonce you have more than two or three branches on the same value.
