1. Introduction
When you’re just starting out with programming, try/catch feels like a fire extinguisher: it hangs on the wall—let it be there. But when you write real code (even console code), it suddenly turns out you need that “extinguisher” all the time: parsing numbers, precondition checks via require, accessing collections, working with external data—all of these are potential sources of exceptions.
The problem is not that try/catch is bad. The problem is that it grows quickly: extra variables appear, blocks become nested, and the code starts to look like a labyrinth. Kotlin helps because try/catch is an expression (you can assign its result to a val). But you still want a more “functional” form: get a result, and then work with it as a chain—like we’re used to with collections (map, filter, etc.).
That’s where runCatching comes in: it turns “code that might throw an exception” into a value of type Result<T>, which you can work with like a regular object: extract the value, handle an error, print a message, return it further up.
2. What runCatching actually does
To be honest, runCatching is not a “new error-handling system.” It’s just a compact wrapper of try/catch into Result. It doesn’t decide for you what to treat as an error and where to handle it. It only changes the shape: instead of “exception as control flow,” you get a “Result value.”
It’s important to keep one more detail in mind: in Kotlin, try/catch returns the value of the last expression in the try block or in the triggered catch block, and finally always runs but does not affect the computed value. Conceptually, this model is very similar: “something happened inside → a result came out.”
Imagine that runCatching conceptually does roughly this (simplified):
fun <T> myRunCatching(block: () -> T): Result<T> =
try {
Result.success(block())
} catch (e: Throwable) {
Result.failure(e)
}
The syntax runCatching { ... } lets you avoid writing all of that every time.
A mini example with an easy-to-grasp task—parsing a number:
fun parseIntViaTry(text: String): Int? {
return try {
text.toInt()
} catch (e: NumberFormatException) {
null
}
}
And now the same thing via runCatching, except we return not Int? but Result<Int>:
fun parseIntResult(text: String): Result<Int> {
return runCatching { text.toInt() }
}
The semantic difference is big: Int? only says “worked/didn’t work,” but doesn’t explain why. Result<Int> stores the exception, meaning it preserves information about the cause of the error.
3. How to get a value out of Result without turning your code into a mess
At this stage, beginners usually feel like doing this: “Oh, Result! That means I’ll extract the value and move on.” And that’s fine—you just need to do it consciously, because this is the moment when you choose your error-handling policy.
In Kotlin, try/catch as an expression already taught us that “everything ends with a value.” Result is the same idea, except the value can be “success” or “failure.”
Let’s look at the most practical extraction methods:
fun main() {
val r1 = runCatching { "123".toInt() }
println(r1.getOrNull()) // 123
val r2 = runCatching { "oops".toInt() }
println(r2.getOrNull()) // null
}
getOrNull() is good when “worked/didn’t work” is truly enough. But there’s a risk: you get null and forget that there was an error inside. It’s almost like turning off an alarm because it’s loud and annoying.
getOrElse { ... } is an option where you provide a fallback value:
fun main() {
val amount = runCatching { "oops".toInt() }
.getOrElse { 0 }
println(amount) // 0
}
This is convenient, but dangerous if 0 changes the meaning of the program. Sometimes 0 is a fine default; sometimes it means “we accidentally recorded zero spending and then wonder why the reports look weird.”
And there’s a “hard” option—getOrThrow(): it returns the value or throws the exception again. This is useful when you don’t want to make the decision at this layer and consider the error fatal.
4. onSuccess and onFailure: branching without if, but with discipline
When you see onSuccess { ... } and onFailure { ... }, your brain happily thinks: “Nice, I can write handling as a clean chain!” And that’s true. But these methods have an important property: they’re meant for side effects (print something, update state, record a message), not for “turning an error into a value.”
That is, onSuccess and onFailure don’t replace getOrElse and don’t “fix” an error. They just provide a convenient place to do something depending on the outcome.
Mini example:
fun main() {
runCatching { "42".toInt() }
.onSuccess { println("Success: $it") } // Success: 42
.onFailure { println("Error: ${it.message}") }
runCatching { "x".toInt() }
.onSuccess { println("Success: $it") }
.onFailure { println("Error: ${it.message}") } // Error: For input string: "x"
}
Notice the psychological effect: even if we haven’t decided what to do next yet, at least we didn’t lose the fact that an error happened—it surfaced in an explicit place.
And this brings us to the key principle of the lecture: an empty onFailure { } is almost always a red flag. If you wrote an error handler that does nothing, you created a “black hole” where the causes of problems disappear.
5. runCatching in a console app: parsing a command
Here’s an important point: we don’t write runCatching “just because we can.” We use it as a tool within a specific layer so the code becomes simpler while the error contract remains clear.
Let’s imagine our practical project is a console expense tracker called BudgetBuddy. It can already accept commands like:
- add 120 food
- list
- remove 3
(We won’t complicate the command list—here we care about the error-handling technique, not the app’s functionality.)
As a sketch, the flow looks like this:
flowchart TD
A["readln()"] --> B["parseCommand(line)"]
B -->|Result.Success| C["executeCommand(cmd)"]
B -->|Result.Failure| D[print error to user]
C --> E[print result]
The idea: command parsing is the perfect place for runCatching because there are many potential exceptions (toInt(), require(...)), and it’s convenient to return the result upward as a Result.
Let’s make a small model for the add command:
data class AddExpenseCommand(
val amount: Int,
val category: String,
)
Now a parser that returns Result<AddExpenseCommand>:
fun parseAddCommand(tokens: List<String>): Result<AddExpenseCommand> {
return runCatching {
require(tokens.size >= 3) { "Usage: add <amount> <category>" }
val amount = tokens[1].toInt()
val category = tokens[2]
AddExpenseCommand(amount = amount, category = category)
}
}
There’s important “magic without magic” here: require(...) throws an exception when it fails (usually IllegalArgumentException), and toInt() can throw NumberFormatException. We don’t catch them manually—we let them happen, and runCatching will wrap them into Result.failure.
Handling the result in the CLI via onSuccess/onFailure
Now let’s show what this looks like at the console layer. We read a line, split it into tokens, and try to parse it.
This part matters because this is where “swallowing” is most often born: a developer sees the error, gets tired, does getOrElse { ... }, and the program stops crashing… but starts behaving strangely.
Let’s make a careful handler:
fun handleLine(line: String) {
val tokens = line.trim().split(" ").filter { it.isNotBlank() }
if (tokens.isEmpty()) return
when (tokens[0]) {
"add" -> parseAddCommand(tokens)
.onSuccess { cmd ->
println("Adding expense: ${cmd.amount}, category=${cmd.category}")
}
.onFailure { e ->
println("add command error: ${e.message}")
}
else -> println("Unknown command: ${tokens[0]}")
}
}
Why this is good: the error doesn’t disappear. We explicitly tell the user what was wrong. In a way, onFailure here is our “human translator” for errors.
And yes, this doesn’t replace the contract: if the error were critical at this level, we wouldn’t print a message—we might terminate the program. But for CLI commands, parsing is often an expected kind of error (“the user typed something wrong”), so a message plus continuing to work is reasonable.
6. Swallowing errors: what it looks like and why it hurts
The word “swallowing” sounds like something from the auto repair world: “We’ll silence the error for you and the ‘Check Engine’ light will stop annoying you.” The light really will stop… but the engine won’t.
In programming, “swallowing” usually happens in two forms.
The first form is when you do getOrNull() and then ignore the null, substituting a default that breaks the meaning:
fun parseAmountUnsafe(text: String): Int {
return runCatching { text.toInt() }.getOrNull() ?: 0
}
If 0 is acceptable, it might be fine. But if 0 means “free” or “no expense,” you just silently changed the business logic. The user typed add abc food, and you added an expense of 0 food. And everything is “successful.” Scarily successful.
The second form is an “empty handler”:
fun parseAmountSilently(text: String): Result<Int> {
return runCatching { text.toInt() }
.onFailure { /* silence */ }
}
From the outside it looks like you “handled the error,” but you didn’t—you just made it invisible.
How do you protect yourself? Two rules almost always help.
Rule one: if you do a fallback (getOrElse), it must be meaningful and ideally accompanied by a message to the user. Rule two: if you’re not ready to make the decision here, don’t make it—return the Result higher up or rethrow the exception.
Example of a “meaningful fallback with an explanation”:
fun parseAmountWithMessage(text: String): Int {
return runCatching { text.toInt() }
.getOrElse { e ->
println("Invalid amount '$text': ${e.message}")
0
}
}
Yes, it’s still a fallback. But now at least it’s clear why it happened.
7. When runCatching is not the best choice: an honest try/catch is better
It’s very tempting to take runCatching and wrap “absolutely everything” in it. But then you turn your program into a set of containers where errors ride along a conveyor belt and never surface.
Also, runCatching catches Throwable (i.e., a potentially too-broad range of problems). Sometimes you need to catch a strictly specific error and handle it as expected, and leave everything else alone. In such cases, try/catch with a concrete exception type reads simpler and is semantically more reliable. And Kotlin directly shows the basic try/catch form and reminds you that try/catch can be an expression that returns a value.
For example, if you want to treat “not a number” as an expected error, and let everything else crash (as a failure), then an honest try/catch is sometimes clearer:
fun parseIntOrZero(text: String): Int {
return try {
text.toInt()
} catch (e: NumberFormatException) {
0
}
}
This reads straightforwardly: “if it’s not a number—return 0.” Meanwhile, runCatching adds an extra layer of abstraction here that isn’t always necessary.
8. Common mistakes when using runCatching
Mistake #1: wrapping too large a chunk of logic in runCatching.
When “half the function” ends up inside runCatching, you lose clarity about which operation could have failed. Then in onFailure you see the exception, but the context gets blurry: was it toInt(), require, list access, or something else? The remedy is simple: wrap the minimal section that can actually throw, especially if you want to provide a human-friendly message afterward.
Mistake #2: writing onFailure { } “just in case.”
An empty onFailure is almost literally “I know an error can happen, but I don’t care.” That’s how bugs appear that can’t be reproduced: the program sometimes behaves oddly, but there are no logs, no messages, and nobody saw the exception. If you add onFailure, at least do something minimal there: show a message, return an Error, stop the flow—any meaningful decision.
Mistake #3: turning every error into a default without analysis (getOrElse { 0 } everywhere).
Sometimes it feels like a default is good: the program “doesn’t crash.” But if the default breaks meaning, you’re just pushing the error further down the line, where it will surface in reports, totals, and user data. This is the case where “it didn’t crash” is worse than “it crashed immediately,” because you preserved an incorrect state.
Mistake #4: confusing changing the shape with handling.
runCatching changes the shape of an error: it turns an exception into Result.failure. But that is not handling. Handling is when you make a decision: continue/stop, show a message to the user, return an Error outward, rethrow the exception. If after runCatching you never made a decision and never surfaced the error, it most likely just disappeared.
Mistake #5: mixing Result<T> and your own sealed result type without a reason.
A project often has its own convention of Ok/Error (for example, in the domain layer). Result is also “success/failure,” but with a different philosophy. If you start returning sometimes Result and sometimes Ok/Error in neighboring functions without a clear rule, the code becomes hard to read. A common approach that works well: inside a layer you can use runCatching and Result, and at the layer boundary convert it into your stable contract (Ok/Error) with a clear message.
Mistake #6: assuming runCatching is the same as validation.
runCatching catches exceptions. But many checks are better expressed not via exceptions but via explicit validation (for example, if/when and returning an Error). If you constantly use exceptions to control normal branching logic, you make the code harder to read. Exceptions are good when something truly “went wrong,” not when the user simply typed an extra space.
GO TO FULL VERSION