RizTech Academy logo
RizTech Academy
Writing Idiomatic KotlinLesson 4 of 530 min

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 throws clutter — 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.
  • require and check are the idiomatic way to throw. require(condition) { message } throws IllegalArgumentException for a bad argument; check(condition) { message } throws IllegalStateException for a bad state. They read as preconditions and are clearer than a hand-written if (...) 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

  1. Write a withdraw function using require and check, and trigger each. Read the exception types and messages.
  2. Rewrite a findUser that throws on "not found" to return User?, and handle the miss with ?:.
  3. Compare toInt() and toIntOrNull() on bad input. Decide when each is appropriate.
  4. Write a parseAmount returning Result<Int> and handle both outcomes with onSuccess/ onFailure.
  5. Model the same parsing as a sealed class and write an exhaustive when. Add a new failure case and watch the when break.
  6. Write an empty catch { }, then explain in a comment why it is dangerous, and fix it to at least log.
  7. For three failures in an app you know (invalid input, item not found, payment declined), decide which representation fits each and why.

Official documentation

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