1. Introduction
When you’re just starting to program, exceptions often feel like lightning: “something hit somewhere, and the program crashed.” But as a project grows, you discover that some errors are actually normal, everyday situations. The user entered not a number, asked to delete a record that doesn’t exist, specified an empty category. That’s not “the end of the world,” it’s simply “no result” — but with an explanation.
And this is where the typical dilemma begins: if you throw exceptions everywhere, the caller code for your function turns into a forest of try/catch. If you return null everywhere, you quickly lose meaning: why null? “Not found”? “Invalid input”? “Business logic broke”? Even in examples for the standard library, Kotlin shows that sometimes it’s better to use safe alternatives like toIntOrNull() instead of crashing with toInt() — exactly because “not a number” is often expected.
Result<T> is one of the tools that helps you say: “A function can finish successfully or with an error, and I’ll return that as a value, not as an explosion.”
2. What is Result<T>: a value or an error in one box
Imagine a box with a “Handle with care” label: inside there’s either something useful (for example, Int), or a note explaining why it didn’t work out (as a Throwable). That is Result<T>: a container that holds one of two outcomes of execution — success with T, or failure with an exception.
An important idea: Result<T> is not magic and not a replacement for every approach. It’s just a way to transport a result that lets you avoid forcing every consumer of your function to write try/catch when the situation is expected. At the same time, the “error” is not a string and not null, but a real exception (type + message + potential cause). This is convenient because exceptions in Kotlin have a rich ecosystem of types (NumberFormatException, IllegalArgumentException, etc.).
You can visualize it schematically like this:
flowchart TD
A[Function does something] --> B{Did it work?}
B -->|yes| C["Result.success(value)"]
B -->|no| D["Result.failure(exception)"]
C --> E[Caller code decides: take the value, use a fallback, or stop]
D --> E
3. How to create Result: success(...) and failure(...)
Creating Result is very simple. There are two basic factory methods:
- Result.success(value) — wrap a value.
- Result.failure(exception) — wrap an error.
Let’s start with a micro example that isn’t tied to the project — just to see the mechanics without getting distracted by domain logic.
fun main() {
val ok: Result<Int> = Result.success(10)
val bad: Result<Int> = Result.failure(IllegalArgumentException("nope"))
println(ok) // Success(10)
println(bad) // Failure(java.lang.IllegalArgumentException: nope)
}
This isn’t a “nice app” yet, but you can already see it: the variable holds an outcome, not “exploded/didn’t explode.”
Now an important practical nuance: the better the exception inside failure, the easier it is later to understand what happened.
4. Returning Result from functions using a project example
So the examples aren’t “in a vacuum,” let’s continue our practical console project (let’s call it BudgetBuddy): it stores expenses and can accept commands add, list, remove. We’re not touching files or the network — that will be other days in the course — so the storage will be just in memory.
In our domain, we have an expense model:
data class Expense(
val id: Int,
val amount: Int,
val category: String,
val note: String
)
Now imagine a typical task: the user enters an amount as text, and we want to parse it into an Int. If we do it “head-on” with toInt(), then on invalid input we’ll get a NumberFormatException.
In earlier lectures we already saw the “soft” parsing option via toIntOrNull(). Now we’ll make a version with an explicit contract: return Result<Int>, where on error we return an exception with a clear message.
fun parseAmount(text: String): Result<Int> {
val trimmed = text.trim()
val n = trimmed.toIntOrNull()
?: return Result.failure(NumberFormatException("Amount is not a number: '$text'"))
if (n <= 0) {
return Result.failure(IllegalArgumentException("Amount must be > 0, got $n"))
}
return Result.success(n)
}
Notice the style: we don’t throw the exception outward; we wrap it as a value. And one more point: we added got ... — this speeds up debugging because the message shows the actual value.
5. Result as a contract: the function does not decide the fate of the error
It’s very easy to misunderstand Result, as if it “handles the error.” It doesn’t handle it. It just neatly says: “Here’s the outcome; you decide what to do next.”
To feel this, let’s write a function that creates an expense but returns Result<Expense>. Inside it uses parseAmount.
fun createExpense(id: Int, amountText: String, category: String, note: String): Result<Expense> {
val amountResult = parseAmount(amountText)
val amount = amountResult.getOrElse { return Result.failure(it) }
val cat = category.trim()
if (cat.isEmpty()) {
return Result.failure(IllegalArgumentException("Category must not be blank"))
}
return Result.success(Expense(id = id, amount = amount, category = cat, note = note.trim()))
}
Something important happened here: we used getOrElse as a way to extract the value or exit on error. This is similar to guard clauses: as soon as we see we can’t continue correctly, we neatly return an error instead of building a huge if across the whole method.
And yes, we could have done it with exceptions, but then the caller would have to write try/catch (or the program would just go “boom” and stop). Our goal is to make the contract explicitly readable right in the type: Result<Expense>.
6. Working with Result in a CLI: extracting, branching, and pipelines
When Result reaches the caller code, we get choices: extract the value, provide a fallback, show a message and end the scenario, or “re-explode” the error (if at this level it’s already considered fatal).
What’s important here is not to act on the principle of “eh, fine, I’ll just put a zero everywhere.” Sometimes zero is a great solution, and sometimes it’s a hidden bug that later turns into broken stats and mysterious reports.
Below is a table of the main methods we’ll use today:
| Method | What it returns | When it fits | Typical risk |
|---|---|---|---|
|
|
when the error “doesn’t matter,” and you can live with null | losing the cause of the error |
|
|
when we want to extract the error separately and show/log it | forgetting to handle success |
|
|
when there is a meaningful fallback or we want to return early from a function | building a “universal hack” |
|
or throws an exception |
when at this level the error is already fatal | turning Result into a pointless wrapper |
Now let’s look at this in short examples.
getOrNull() — turning Result<T> into T?
This is useful when we deliberately want “soft behavior”: either a number, or null. In Kotlin, this style is already familiar from toIntOrNull() and from collection OrNull methods.
fun main() {
val n: Int? = parseAmount("123").getOrNull()
println(n) // 123
val bad: Int? = parseAmount("x").getOrNull()
println(bad) // null
}
The downside is obvious: the reason the value was lost disappears. That’s why getOrNull() is a conscious choice: “I don’t care about the cause.”
exceptionOrNull() — extracting an exception as data
If it’s important to explain to the user why the command didn’t run, we usually want to get the error text.
fun main() {
val r = parseAmount("x")
val msg = r.exceptionOrNull()?.message ?: "OK"
println(msg) // Amount is not a number: 'x'
}
Note: exceptionOrNull() returns Throwable?, so safe calls ?. are basically begging to be used here.
getOrElse { ... } — a meaningful fallback or an early return
getOrElse is good when you truly know what to do on error. For example, if the user didn’t enter a note, you can substitute “(no note)”. But doing that with money is dangerous.
Let’s show a case where a fallback is reasonable:
fun normalizedNote(note: String): Result<String> =
if (note.isBlank()) Result.failure(IllegalArgumentException("Empty note"))
else Result.success(note.trim())
fun main() {
val note = normalizedNote(" ").getOrElse { "(no note)" }
println(note) // (no note)
}
Here the fallback is part of UX, not “sweeping the problem under the rug.”
getOrThrow() — returning to the world of exceptions
Sometimes Result is needed at a lower level to neatly collect an error, but at a higher level we decide: “If there’s an error here, that’s a stop; we can’t continue.” Then getOrThrow() honestly throws the exception outward.
fun main() {
val amount = parseAmount("x").getOrThrow()
println(amount) // we won't reach here
}
Why isn’t this pointless? Because the policy can differ across layers. A lower layer can “wrap” errors, and an upper layer can decide that at this point the scenario must terminate.
onSuccess {} and onFailure {}: branching without try/catch
When you write a console UI, you often need to do something simple: on success — print OK, on error — print ERROR: …. And this is where onSuccess / onFailure help you avoid turning everything into chains of if.
Let’s make a small function that prints the result of a command:
fun printCommandResult(result: Result<String>) {
result
.onSuccess { println("OK: $it") }
.onFailure { println("ERROR: ${it.message}") }
}
And an example of usage:
fun main() {
val ok = Result.success("Expense added")
val bad = Result.failure(IllegalArgumentException("Wrong command"))
printCommandResult(ok) // OK: Expense added
printCommandResult(bad) // ERROR: Wrong command
}
This still isn’t a “complete app,” but you can already see it: branching became neat and local. We don’t have to catch the exception — it’s already “in the box,” and we just decide how to display it.
Mini pipeline for the add command in main
Let’s assemble a small piece that looks like real command input. Recall that in previous days we already did string parsing, normalization, split, commands, etc. Here we’ll just show how Result helps avoid turning main into a huge try/catch.
Command:
add <amount> <category> <note...>
For example:
add 120 food Coffee
fun handleAddCommand(input: String, nextId: Int): Result<Expense> {
val parts = input.trim().split(" ")
if (parts.size < 4) {
return Result.failure(IllegalArgumentException("Usage: add <amount> <category> <note...>"))
}
val amountText = parts[1]
val category = parts[2]
val note = parts.drop(3).joinToString(" ")
return createExpense(id = nextId, amountText = amountText, category = category, note = note)
}
And here’s what it might look like in main:
fun main() {
val input = "add x food Coffee"
val result = handleAddCommand(input, nextId = 1)
result
.onSuccess { println("Added: $it") }
.onFailure { println("Cannot add: ${it.message}") }
// Cannot add: Amount is not a number: 'x'
}
What’s important here is that the “not a number” error is a NumberFormatException, a typical formatting failure. But we don’t let it “blow up” the program. We turn it into a controlled outcome.
How not to turn Result into an “error hider”
Result is easy to misuse: wrap an error, mumble something to the console, and continue as if everything is fine. The result is a very “survivable” program… that starts lying. It’s almost like taping over the “Check engine” light: it gets quieter, but the problem didn’t go away.
To avoid this, it helps to keep a rule in mind: after an error, there must always be a decision. In a CLI program there are usually three: show a message and continue the loop, show a message and terminate the program, or provide a meaningful fallback. If you did onFailure { } and did nothing inside, it’s as if you said: “I saw a fire and decided to let it keep burning; I just won’t look.”
A mini example of “good discipline” is turning Result into a string that the CLI prints:
fun toCliMessage(result: Result<Expense>): String {
val e = result.exceptionOrNull()
return if (e != null) {
"ERROR: ${e.message}"
} else {
val expense = result.getOrThrow()
"OK: added id=${expense.id}, amount=${expense.amount}"
}
}
There’s a subtlety here: we first checked for an error, and only then used getOrThrow() — meaning we “explode” only if we violated our own logic (if for some reason the error is null but there is no value — which usually doesn’t happen with a correct Result). This style is similar to fail-fast: you should crash where the program logic broke, not where the user simply entered x instead of a number.
7. Common mistakes when working with Result<T>
Mistake #1: turning Result into null via getOrNull() and forgetting there was an error at all.
This often happens when you just want things to “work somehow.” You lose the reason and deprive yourself of the ability to explain to the user what’s wrong. If UX needs the “why,” it’s better to extract exceptionOrNull() and form a message, rather than silently getting null.
Mistake #2: using getOrElse { 0 } as a universal hack.
For an expense amount, 0 may look harmless, but it can hide a real problem: for example, the user entered garbage, and you quietly recorded an expense of 0. Later the reports “for some reason” don’t match. A fallback must be meaningful in the context of the task, not just convenient for the compiler.
Mistake #3: writing Result.failure(Exception("oops")) without meaning and without real data.
The message oops might be funny for exactly the first 3 seconds, until you start debugging. A good message should say what was expected and what was received, including the actual value (got ...).
Mistake #4: using onFailure { } as a “muffler” and continuing as if nothing happened.
Result doesn’t have to “crash” the program — and that’s a plus. But if on error you didn’t choose a policy (stop, continue, fallback), then you didn’t handle the error, you hid it. At that point bugs start traveling quietly through the code and find you on the most inconvenient day.
Mistake #5: returning Result from a function, and at the first call site doing getOrThrow() everywhere.
This turns Result into an extra wrapper: formally you “moved” the exception into a value, but then immediately moved the exception back without any readability benefits. If you chose the Result contract, give the caller code a chance to consciously handle the outcome via onSuccess/onFailure, getOrElse, or transforming it into a clear message.