try as an Expression
Kotlin does something Java’s try statement never could: it turns try/catch into an expression that produces a value. Instead of declaring a mutable variable, running risky code inside a try block, and reassigning that variable from inside catch, you can write the whole thing as the right-hand side of a single val assignment. This keeps error-recovery code compact, keeps your variables immutable, and avoids the classic bug of a variable that is only definitely initialized on some code paths. This lesson covers exactly how try-as-an-expression works, how Kotlin infers its type, and the mistakes that trip people up.
Overview / How it works
In Java, try is purely a statement: it controls flow, but it cannot appear on the right-hand side of an assignment. If you want a value out of a try/catch block in Java, you declare a variable above the block with a placeholder value (or leave it uninitialized and hope every branch assigns it) and mutate it inside. Kotlin removes that ceremony. Almost everything in Kotlin that looks like a control-flow construct — if, when, and try — can also be evaluated as an expression, meaning it produces a value the compiler can use directly.
A try expression evaluates to the value of the last expression in whichever block actually finished executing. If the code inside try runs to completion without throwing, the value of the try block’s last line becomes the value of the whole expression. If an exception is thrown and a matching catch clause runs, the value of that catch block’s last line becomes the value instead. Only one of these branches ever contributes a value for a given execution — you never get both.
A finally block, if present, always runs after either path completes, but it does not contribute to the expression’s value — its only job is guaranteed cleanup (closing a resource, logging, releasing a lock). This matters enough to say twice: never rely on finally to produce or transform the result. In fact, if a finally block contains its own return, it will silently discard whatever the try or catch block computed (and even swallow an in-flight exception) — legal Kotlin, but a serious anti-pattern.
Type inference works the same way it does for if expressions: the compiler looks at the type produced by the try block and the type produced by every catch block, and infers the least upper bound (the narrowest common supertype) as the type of the whole expression. If you declare an explicit target type (as in val x: Int = try { ... } catch (...) { ... }), then every branch must be assignable to that type, and the compiler enforces it just like any other assignment.
If none of the catch clauses match the thrown exception, the exception is not swallowed — it propagates up the call stack exactly as it would from any other try statement. In that case the expression is never “completed” at that call site at all; the assignment simply never happens because control has already left the function.
Syntax
val result = try {
// code that might throw
riskyOperation()
} catch (e: SomeException) {
// runs only if riskyOperation() throws SomeException
fallbackValue
} finally {
// always runs; return value here is ignored
}
| Part | Meaning |
|---|---|
try { ... } |
Code that might throw. Its last expression is the value used if no exception occurs. |
catch (e: SomeException) { ... } |
Runs only if a matching exception type is thrown. You may chain several catch clauses for different exception types; the first matching one (top to bottom) runs. Its last expression is the value used for that path. |
finally { ... } |
Optional. Always runs, for cleanup only. Its value is discarded and never becomes the expression’s result. |
| Resulting type | The common supertype of the try block’s type and every catch block’s type, or the explicitly declared target type if one is annotated. |
Examples
Example 1: parsing a string safely
fun main() {
val input = "42"
val number: Int = try {
input.toInt()
} catch (e: NumberFormatException) {
-1
}
println("Parsed number: $number")
}
Output:
Parsed number: 42
String.toInt() throws NumberFormatException when the string isn’t a valid integer. Here "42" parses cleanly, so the try block’s value (42) becomes the value assigned to number. The catch block never runs, but the compiler still requires it to produce an Int, because either branch might run at runtime.
Example 2: handling both outcomes in a loop
fun main() {
val inputs = listOf("42", "abc", "100")
for (input in inputs) {
val number: Int = try {
input.toInt()
} catch (e: NumberFormatException) {
println("Could not parse '$input', defaulting to 0")
0
}
println("Result: $number")
}
}
Output:
Result: 42
Could not parse 'abc', defaulting to 0
Result: 0
Result: 100
Each iteration declares its own immutable number. For "42" and "100" the try block succeeds and supplies the value directly. For "abc", toInt() throws, the catch block runs a side-effecting println as a warning, and then its last line, 0, becomes the value of number. Notice that a catch block can contain multiple statements — only the very last one supplies the expression’s value.
Example 3: a realistic case with finally and a nullable result
fun safeDivide(a: Int, b: Int): Int? {
return try {
a / b
} catch (e: ArithmeticException) {
println("Division by zero!")
null
} finally {
println("Attempted division of $a by $b")
}
}
fun main() {
val results = listOf(safeDivide(10, 2), safeDivide(5, 0), safeDivide(9, 3))
for (r in results) {
val display = r?.toString() ?: "undefined"
println("Result: $display")
}
}
Output:
Attempted division of 10 by 2
Division by zero!
Attempted division of 5 by 0
Attempted division of 9 by 3
Result: 5
Result: undefined
Result: 3
Integer division by zero doesn’t produce infinity or NaN in Kotlin — it throws ArithmeticException at runtime, just like in Java. safeDivide returns the try expression directly. For (5, 0), the division throws, the catch block prints a message and evaluates to null, and finally then prints its own message before the function actually returns. Because the return type is Int?, the compiler happily unifies the try block’s Int with the catch block’s null (whose type is Nothing?) into Int?. Note that the “Attempted division…” lines print during the construction of the results list — before any “Result:” line — because listOf(...) evaluates its arguments eagerly, left to right, before the loop even starts.
How it works step by step
Walking through what the runtime actually does for val number: Int = try { input.toInt() } catch (e: NumberFormatException) { 0 }:
- Execution enters the
tryblock and runsinput.toInt(). - If it completes without throwing, its result becomes the pending value of the whole expression, and the runtime skips straight to any
finallyblock. - If it throws
NumberFormatException, the JVM unwinds to the nearest enclosingcatchclause whose declared type matches (or is a supertype of) the thrown exception, checked top to bottom if there are several. That clause’s body runs, and its last expression becomes the pending value instead. - If no
catchclause matches, the exception keeps propagating outward past thistryentirely — the assignment tonumbernever happens, and anyfinallyblock still runs on the way out. - Whether the path was “try succeeded” or “catch handled it,” the
finallyblock (if present) now runs unconditionally, purely for its side effects. - The pending value from step 2 or 3 is finally assigned to
number.
Common Mistakes
Mistake 1: declaring the value inside try instead of capturing the expression
fun main() {
try {
val value = "abc".toInt()
} catch (e: NumberFormatException) {
println("failed")
}
println(value)
}
This looks reasonable but fails to compile: value is a local variable scoped to the try block, so it doesn’t exist once the block ends — println(value) is an unresolved reference. This is exactly the problem try-as-an-expression exists to solve.
val value: Int = try {
"abc".toInt()
} catch (e: NumberFormatException) {
-1
}
println(value)
By assigning the whole try/catch to value, the variable lives at the outer scope with a guaranteed value on every path, and there’s no intermediate variable trapped inside the block.
Mistake 2: forgetting to end the catch block with the fallback value
val number: Int = try {
"abc".toInt()
} catch (e: NumberFormatException) {
println("bad input")
}
It’s easy to add a logging line to a catch block and forget that whatever statement comes last is now the expression’s value. Here the last statement is println(...), which returns Unit, not Int — so this fails to compile with a type mismatch between Unit and the declared Int.
val number: Int = try {
"abc".toInt()
} catch (e: NumberFormatException) {
println("bad input")
-1
}
println(number)
Adding -1 as the final line restores a value of the right type; the println call is still there, but it’s no longer the last statement.
Mistake 3: letting the type widen instead of matching branch types
val result: Int = try {
"42".toInt()
} catch (e: NumberFormatException) {
"unknown"
}
The try block produces an Int but the catch block produces a String. With an explicit Int target type, this is a straightforward type-mismatch compile error. Even without an explicit type, the compiler would infer some unhelpful common supertype (like Comparable<*> and Any) instead of the specific type you actually want, silently defeating the point of static typing.
val result: Int = try {
"42".toInt()
} catch (e: NumberFormatException) {
-1
}
println(result)
Output:
42
Keep every branch’s type consistent (or annotate the target type explicitly) so the compiler can hold you to it.
Best Practices
- Give the
valan explicit type annotation whenever thetryandcatchbranches might otherwise infer a broad or surprising common supertype. - Keep the code inside
tryas small as possible — ideally just the one call that can actually throw — so it’s obvious what a givencatchis guarding against. - Catch the most specific exception type you can (
NumberFormatExceptionrather thanException); a broad catch can silently absorb bugs that have nothing to do with the failure you intended to handle. - Never use
finallyto compute or override the result — use it only for cleanup (closing streams, releasing locks, logging) that must run no matter what. - Remember a
catchblock can hold several statements; make sure the very last one is the value you intend to return, not a leftover logging call. - For functional-style error handling without exceptions at all, look into the standard library’s
runCatching, which wraps a computation in aResultobject — a related but distinct tool worth learning once you’re comfortable withtryas an expression.
Practice Exercises
- Write a function
parseOrNull(text: String): Int?that usestryas an expression to return the parsed integer, ornullif parsing fails. Call it with"7"and"seven"and print both results. - Extend the
safeDivideexample so it also catches a case where the input can’t be represented (for example, wrap the divisor parsing itself with a secondtryexpression) and returns a sensible default instead of propagating the exception. - Take the broken snippet from Mistake 3 (mismatched
Int/Stringbranches) and fix it two different ways: once by changing thecatchbranch’s value, and once by changing the declared type ofresultto something both branches could share. Which fix keeps the code more useful, and why?
Summary
try/catchin Kotlin is an expression: it evaluates to the last expression of whichever block (tryor the matchingcatch) actually ran.finallyalways runs but never contributes to the result — use it strictly for cleanup side effects.- The compiler infers the expression’s type as the common supertype of all branches, or enforces an explicit target type if you declare one — keep branch types matched to avoid surprises.
- An uncaught exception still propagates normally; the expression simply never completes at that call site.
- Using
tryas an expression avoids the classic Java pattern of a placeholder variable mutated from inside a block, keeping your variablesvaland your error handling compact.
