Error handling: exceptions, nullability and Result
Things go wrong — a network fails, input is malformed, a file is missing. How you represent going
wrong is a design decision with real consequences for how safe and readable your code is. Kotlin
gives you three tools — exceptions, nullable types, and Result — and knowing which to
reach for is a mark of engineering maturity.
Exceptions — for the genuinely exceptional
An exception interrupts normal flow and unwinds until something catches it:
fun withdraw(amountPaise: Int) {
require(amountPaise > 0) { "amount must be positive" } // throws IllegalArgumentException
check(balancePaise >= amountPaise) { "insufficient funds" } // throws IllegalStateException
balancePaise -= amountPaise
}
try {
withdraw(50000)
} catch (e: IllegalStateException) {
println("Cannot withdraw: ${e.message}")
}
Two things about Kotlin exceptions specifically:
- Kotlin has no checked exceptions. Unlike Java, you are never forced by the compiler to
declare or catch an exception. This removes Java's
throwsclutter — but it also means the compiler will not remind you that a function can throw, so you must know from its documentation or its name. requireandcheckare the idiomatic way to throw.require(condition) { message }throwsIllegalArgumentExceptionfor a bad argument;check(condition) { message }throwsIllegalStateExceptionfor a bad state. They read as preconditions and are clearer than a hand-writtenif (...) throw ....
Use exceptions for genuinely exceptional, unexpected conditions — a bug, a violated precondition, a resource that should exist but does not. They are the right tool when the failure is not part of normal operation and there is no sensible way to continue.
Nullable types — for "might not be there"
The failure you have already met: a value that legitimately might be absent. Do not throw an exception for an expected absence — use a nullable return (the null-safety module):
// wrong: an expected "not found" is not exceptional
fun findUser(id: Int): User {
return users.find { it.id == id } ?: throw NoSuchElementException()
}
// right: "not found" is a normal outcome, so model it as nullable
fun findUser(id: Int): User? = users.find { it.id == id }
// the caller handles the absence cleanly, no try/catch
val name = findUser(42)?.name ?: "Unknown"
A lookup that finds nothing is a normal result, not an emergency. Returning User? makes the caller
handle the miss with the graceful tools — ?., ?:, let — instead of wrapping every call in
try/catch. The rule: exceptions for the exceptional; nullable for the expected-absent. Getting
this boundary right is most of good error design.
Kotlin's standard library follows this exactly: first() throws if the list is empty (you asserted
there is one), firstOrNull() returns null (you accept there might not be). toInt() throws on bad
input; toIntOrNull() returns null. The OrNull suffix is the library telling you "this failure is
expected, here is the non-throwing version". Prefer the OrNull variants when absence is a normal
possibility.
Result — for "it worked, or here is why not"
Sometimes a failure carries information the caller needs to act on — an error message, a code, a
reason. Nullable says "nothing came back" but not why; an exception forces try/catch. For "it
succeeded with a value, or failed with a reason", model the outcome as a type. Kotlin's
Result<T> does this:
fun parseAmount(input: String): Result<Int> {
val n = input.toIntOrNull()
?: return Result.failure(IllegalArgumentException("'$input' is not a number"))
if (n <= 0) return Result.failure(IllegalArgumentException("amount must be positive"))
return Result.success(n)
}
val result = parseAmount("abc")
result
.onSuccess { println("Parsed ₹$it") }
.onFailure { println("Failed: ${it.message}") } // Failed: 'abc' is not a number
Result is one of two things — success with a value or failure with a Throwable — and the caller
handles both without try/catch. This is the functional, type-safe way to carry a failure reason,
and it composes well.
Even better for a domain with specific failure kinds is your own sealed class (module 6), which lets each failure carry exactly the data it needs and gives you exhaustive handling:
sealed class ParseOutcome {
data class Valid(val amount: Int) : ParseOutcome()
data class NotANumber(val input: String) : ParseOutcome()
object NotPositive : ParseOutcome()
}
Now a when over ParseOutcome is exhaustive — the compiler ensures every failure case is handled,
and adding a new one breaks every when until you handle it. For rich domain errors, a sealed class
beats both exceptions and Result, because it makes the failure modes explicit and checked.
Choosing — the decision table
| The failure is… | Use |
|---|---|
| A bug or violated precondition (should never happen) | Exception (require/check) |
| An expected absence ("not found", "no value") | Nullable (T?, the OrNull variants) |
| Success-or-reason, caller acts on the reason | Result<T> |
| A domain with specific, exhaustive failure kinds | A sealed class |
The habits that matter
Beyond choosing the representation, a few rules keep error handling honest:
- Never swallow an exception silently. An empty
catch { }— catching an error and doing nothing — hides bugs and is one of the worst things in a codebase. At minimum, log it; usually, handle it or rethrow. - Catch specific exceptions, not
Exception.catch (e: IOException)says what you expect;catch (e: Exception)catches everything including bugs you meant to let crash. - Fail with a message that explains the invariant.
require(qty > 0) { "qty must be positive, was $qty" }tells the next developer what was violated and what the bad value was — far better than a bare exception with no clue (the database course's?: error("why")point, again). - Handle failure at the boundary. Validate and convert at the edge of your program (input, network) so the core works with clean, valid, non-null data — the same "handle null at the boundary" idea from null safety.
Error handling is not glamorous, but it is where careful engineers are separated from careless ones. A
codebase that models its failures honestly — exceptions for bugs, nullable for absence, sealed types
for domain outcomes, and never a silent catch — is one you can trust and change without fear.
Check your work
When to use an exception. For genuinely exceptional, unexpected conditions — bugs, violated preconditions.
Kotlin's difference from Java on exceptions. No checked exceptions — you are never forced to declare or catch, so a function's throwing is not compiler-visible.
The idiomatic way to throw. require (bad argument → IllegalArgumentException) and check
(bad state → IllegalStateException).
When to use a nullable return instead. For an expected absence — "not found" is normal, not
exceptional; prefer the OrNull variants.
What Result<T> is for. Success-with-a-value or failure-with-a-reason, handled without
try/catch.
When a sealed class beats both. For a domain with specific failure kinds needing exhaustive, compiler-checked handling.
Why an empty catch is dangerous. It swallows errors silently and hides bugs — always at least
log.
Why catch specific exceptions. catch (e: Exception) also catches bugs you meant to let crash.
Where to handle failure. At the boundary, so the core works with clean, valid data.
Practice
- Write a
withdrawfunction usingrequireandcheck, and trigger each. Read the exception types and messages. - Rewrite a
findUserthat throws on "not found" to returnUser?, and handle the miss with?:. - Compare
toInt()andtoIntOrNull()on bad input. Decide when each is appropriate. - Write a
parseAmountreturningResult<Int>and handle both outcomes withonSuccess/onFailure. - Model the same parsing as a sealed class and write an exhaustive
when. Add a new failure case and watch thewhenbreak. - Write an empty
catch { }, then explain in a comment why it is dangerous, and fix it to at least log. - For three failures in an app you know (invalid input, item not found, payment declined), decide which representation fits each and why.
Official documentation
- Kotlin — Exceptions — Throwing, catching, and why there are no checked exceptions.
- Kotlin — require, check, error — The idiomatic precondition functions.
- Kotlin — Result — The success-or-failure type and its operators.
- Kotlin — Null safety — Nullable returns for expected absence.
Next: reading code like a reviewer.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship