1. Why turn try/catch into an expression
When you’re just getting familiar with exceptions, try/catch looks like “safety brackets”: wrap dangerous code — and it seems not to crash (if you’re lucky). But as soon as the program gets a bit more complex, the typical pain begins: in one place you parse a number, in another you divide, in a third you check preconditions, and everywhere you want to get a value (or a clear fallback), not “a pile of vars and flags”.
Kotlin supports a neat style: try is an expression, meaning it can return a value either from try or from catch. This is considered the idiomatic approach.
Imagine you’re building a CLI app (our practical project v1) and reading user commands. The user can enter the expense amount as 120, or as one hundred twenty (romantic, but not computer-friendly). You have two options: either write spaghetti with variables and if, or turn “try to parse” into an expression and get a neat result.
2. try/catch as an expression: syntax and types
Expressions in Kotlin are not higher math
Before touching try, it’s important to catch the idea once again: in Kotlin, many constructs can return a value. You’ve already seen this with if and when, where you can do:
val label = if (x > 0) "positive" else "non-positive"
try/catch works similarly: it can also return a value. And the returned value is determined by the last executed expression inside try or inside the matching catch.
This gives us a style: “compute → return” right inside the construct, without intermediate variables. And the code starts to look like a story, not like meeting minutes.
Basic syntax: val x = try { ... } catch { ... }
The key snippet you’ll use many times: in Kotlin, try can be assigned to a val. This is normal and expected: “use try-catch as an expression”.
The simplest example: “parse a number, otherwise return 0”:
fun parseOrZero(text: String): Int =
try {
text.toInt()
} catch (e: NumberFormatException) {
0
}
fun main() {
println(parseOrZero("42")) // 42
println(parseOrZero("forty")) // 0
}
Notice a small but important detail: both try and catch end with a value of the same type (Int). Thanks to this, the whole try/catch is also an Int.
If you suddenly make it so that try returns an Int, while catch returns a String, the compiler will quickly make it clear that it’s not telepathic and doesn’t want to guess “what this is supposed to be”. Sometimes the common type can become Any, but usually that’s a signal that “you’re doing something weird”, and the reader of the code will be sad later.
Mini table: where the value of a try-expression comes from
| What happened in try | What the try/catch expression returns |
|---|---|
| No error occurred | the last expression in the try block |
| An error occurred and was caught | the last expression in the matching catch block |
When try is the whole point of the function: return try { ... } catch { ... }
Sometimes a function is so simple that you can truly describe it with a single try/catch. Then you can write it without intermediate variables and even without val.
It looks like this:
fun safeDivide(a: Int, b: Int): Int =
try {
a / b
} catch (e: ArithmeticException) {
0
}
fun main() {
println(safeDivide(10, 2)) // 5
println(safeDivide(10, 0)) // 0
}
The idea is simple: “if division is impossible, return a fallback”. Of course, choosing “0” is part of the function’s contract. In some tasks that’s fine, in some it’s dangerous (because 0 can look like a valid result). What matters is that the syntax lets you express the decision succinctly and readably.
3. Practice: parsing commands in a CLI
Neat parsing of command arguments
Now let’s tie this to our console app (practical project v1). Suppose we have a command:
add <amount> <category>
The user enters a line, we split it into parts, and we need to get the number cleanly. Yes, you know toIntOrNull(), but today we’re specifically practicing try/catch as an expression, because sometimes it’s not only toInt() that can blow up, but also index access and some other things.
Let’s make a small utility: “try to take a token and parse it into an Int”:
fun parseAmountToken(tokens: List<String>, index: Int): Int? =
try {
tokens[index].toInt()
} catch (e: Exception) {
null
}
fun main() {
val tokens = listOf("add", "120", "food")
println(parseAmountToken(tokens, 1)) // 120
println(parseAmountToken(tokens, 9)) // null
println(parseAmountToken(listOf("add", "hundred"), 1)) // null
}
Here we returned an Int?: if everything is OK — a number, if not — null. This is a “soft” contract: the caller decides what to do next (show a hint to the user, ask to re-enter, and so on). In previous days we already discussed that null is appropriate for “couldn’t get a value”, especially when the exact reason is not important.
But here’s an important point: catching Exception is often a bad idea. We’re doing it now for the sake of demonstrating “different things can fail in one place”. A bit later we’ll discuss how to do this more precisely and safely.
“Read → tried to process → formed a response”
In our project we already tried to separate input/CLI from the “domain” and storage. Now we’re interested in one practical thing: the user’s command almost always arrives as a string, and strings are almost always capable of being “creative”.
Let’s make a small function that tries to handle the remove <index> command and returns text for the user. We intentionally won’t touch files/DB (that will come later in the course); we’ll just show the style.
fun handleRemoveCommand(line: String): String {
val tokens = line.trim().split(" ").filter { it.isNotBlank() }
val index = try {
tokens[1].toInt()
} catch (e: Exception) {
return "Usage: remove <index>"
}
return "Pretend we removed item #$index"
}
fun main() {
println(handleRemoveCommand("remove 3")) // Pretend we removed item #3
println(handleRemoveCommand("remove x")) // Usage: remove <index>
println(handleRemoveCommand("remove")) // Usage: remove <index>
}
Technically, here we’re still catching too broadly (Exception). But from the point of view of code-reading architecture, the example is useful: try is simply a way to express “try to get a value; otherwise, quickly exit with a clear message”.
A slightly better (and more honest) approach would be to separate two cases: “no token” and “not a number”. But today our focus is to see the style of try as an expression and as part of logic with an early exit.
4. Nuances and style: how not to ruin readability
Multiple catch blocks: order matters
When you start writing “grown-up” catch blocks, you quickly find out: there can be several kinds of errors, and you may want to handle them differently. Kotlin allows multiple catch blocks, and the first matching one “wins”. The order matters: more specific exceptions must go first, more general ones later.
An example at the idea level (not tied to business logic):
fun parseAndValidatePositive(text: String): Int? =
try {
val n = text.toInt()
require(n > 0) { "must be > 0" }
n
} catch (e: NumberFormatException) {
null
} catch (e: IllegalArgumentException) {
null
}
fun main() {
println(parseAndValidatePositive("10")) // 10
println(parseAndValidatePositive("x")) // null
println(parseAndValidatePositive("-5")) // null
}
Here you see two different sources of trouble: toInt() can throw NumberFormatException, and require(...) can throw IllegalArgumentException. We spelled this out explicitly in code. This reads better than “one big catch (e: Exception) and magic inside”.
Keep try small: the “dangerous section” should be short
One of the most common reasons for “unreadable code with exceptions” is a huge try that wraps half the function. Then it’s unclear: what exactly could fail? what exactly do we want to handle? and are we accidentally catching something important that we shouldn’t catch?
Good style is to keep try as narrow as possible: only around the part that can actually throw and that you actually want to handle.
Let’s compare two styles.
Bad style (try is too broad):
fun formatExpenseLine(amountText: String, category: String): String =
try {
val amount = amountText.toInt()
val normalizedCategory = category.trim().lowercase()
"Expense(amount=$amount, category=$normalizedCategory)"
} catch (e: NumberFormatException) {
"Bad input"
}
Here, inside try you have parsing, string normalization, and formatting. If something goes wrong (and strings can surprise you), you’ll catch only NumberFormatException, but the reader will think “all of this is dangerous”.
A cleaner style (narrow try + clear result assembly):
fun formatExpenseLine(amountText: String, category: String): String {
val amount = try {
amountText.toInt()
} catch (e: NumberFormatException) {
return "Bad amount: '$amountText'"
}
val normalizedCategory = category.trim().lowercase()
return "Expense(amount=$amount, category=$normalizedCategory)"
}
Yes, the code is a couple of lines longer. But now it’s crystal clear where the risk is, which error is expected, and why an early return makes sense here.
finally: it’s not “another way to return a value”
With finally, beginners often get a “magical” idea: supposedly you can tweak the result in finally or “definitely return the correct value”. Unfortunately (or fortunately), you can’t and shouldn’t.
finally is a block that runs no matter what: whether there was an error or not. It exists for cleanup actions. At the same time, it does not change the result of try-catch as an expression.
Let’s show it with a small example:
fun divideWithLog(a: Int): Int {
try {
return 44 / a
} catch (e: ArithmeticException) {
return -1
} finally {
println("cleanup: log something") // cleanup: log something
}
}
fun main() {
println(divideWithLog(0)) // cleanup: log something
// -1
}
Even if you do a return in try or in catch, finally will still run. This is useful when you must reliably close a resource, restore a flag to its original state, or write a log.
It’s also important to remember the formal rule: you can’t write try “by itself”; it must come with at least catch or finally. That is, try { ... } without anything else won’t compile.
Kotlin 2.x nuance: smart cast in catch/finally became more honest
This is a small but nice point specifically for Kotlin 2.x (and our course targets Kotlin 2.3). Previously, the compiler could sometimes be overly optimistic about smart casts around try/catch, but in Kotlin 2.0+ it was improved so that type information “survives” catch and finally more correctly.
The practical meaning for a beginner is simple: if you have a nullable variable and you change it inside try, then in catch the compiler will be more cautious and will more often require ?. (and that’s good, because it protects you from false confidence).
In this lecture we won’t intentionally complicate the examples with nullable logic, but if you see the compiler “suddenly” asking for a safe-call in catch, that’s not being picky — it’s discipline.
5. Common mistakes when using try/catch as an expression
Mistake #1: making try huge “just in case”.
When half the function ends up in try, the code stops answering the main question: “What exactly are we trying to protect?” As a result, you catch not only the expected error (for example, a number format issue) but also random problems that you’d better not hide. The fix is simple: try should wrap the minimal risky section.
Mistake #2: catching Exception when you can catch a specific type.
catch (e: Exception) sounds like “I caught everything; I’m awesome.” In practice, it often means “I accidentally hid a bug.” If you expect a specific problem (for example, NumberFormatException from toInt()), catch that. With multiple catch blocks, place them from more specific to more general.
Mistake #3: trying to use finally to choose the result.
finally is not meant to “swap out” the return value. It’s about actions that must happen no matter what, and it does not change the result of try/catch as an expression. If you need to pick a value, pick it in try/catch, and leave finally for cleanup.
Mistake #4: returning a “default” just so it doesn’t crash, without thinking about meaning.
catch { 0 } looks nice, but sometimes it’s a quiet disaster: the program continues with incorrect data. A default must be part of the contract and must actually make sense. If it doesn’t — it’s better to return null, a clear message to the user, or terminate the scenario.
Mistake #5: mixing error handling and business logic in one try.
If inside try you parse input, mutate application state, and print a report, then on an exception you end up with a “half-done action”. In console apps this is especially unpleasant: the user sees that “something happened” but doesn’t understand how much. It’s better to obtain values first (via try as an expression) and only then perform actions once the inputs are validated.
GO TO FULL VERSION