Errors & Result

Exceptions in Kotlin, try as an expression, require/check/error, Result and runCatching, and custom exceptions.

try expression require check error Result<T> runCatching
๐Ÿšจ

Exceptions in Kotlin

Java Interop covers the interop consequence of this: Kotlin has no checked exceptions at all, so the compiler never forces a try/catch or a throws declaration anywhere. throw, try, catch, and finally otherwise look and behave exactly like their Java counterparts โ€” the difference is entirely about what the compiler requires you to handle, not the runtime mechanics of unwinding the stack.

Ordinary throw / try / catch / finally try/catch
fun divide(a: Int, b: Int): Int {
    if (b == 0) throw ArithmeticException("division by zero")
    return a / b
}

try {
    println(divide(10, 0))
} catch (e: ArithmeticException) {
    println("caught: ${e.message}")
} finally {
    println("cleanup, always runs")
}
๐ŸŽฏ

try as an Expression

Like if and when (see Basics), try can be used as an expression โ€” the whole try/catch block evaluates to a value, with the last expression of whichever branch actually ran (the try body, or a matching catch) becoming the result.

Assigning the result of a try/catch directly try-expr
fun parseIntOrDefault(input: String): Int {
    // The whole try/catch evaluates to a value โ€” no mutable
    // placeholder variable declared above and assigned inside
    val result = try {
        input.toInt()
    } catch (e: NumberFormatException) {
        0
    }
    return result
}

println(parseIntOrDefault("42"))    // 42
println(parseIntOrDefault("oops"))  // 0
โ„น๏ธ
For the specific case of parsing a number with a fallback, input.toIntOrNull() ?: 0 is shorter and avoids exceptions for control flow entirely โ€” reach for the try-expression form when the risky operation doesn't already have a *OrNull() counterpart in the standard library.
โœ…

require, check, error

These three are ordinary standard library functions, not keywords, but they read like assertions and are the idiomatic way to fail fast on a broken precondition. They differ in what kind of failure they signal: require for invalid arguments the caller passed in, check for invalid internal state the function itself discovered, and error for an unconditional "this code path should be unreachable."

require: validate arguments require
// require throws IllegalArgumentException if the condition is false
fun withdraw(balance: Int, amount: Int): Int {
    require(amount > 0) { "amount must be positive, got $amount" }
    require(amount <= balance) { "insufficient funds: $amount > $balance" }
    return balance - amount
}
// withdraw(100, -5)   // throws: "amount must be positive, got -5"
check: validate internal state check
// check throws IllegalStateException โ€” same shape as require,
// but for state the function itself is responsible for, not caller input
class Connection {
    private var connected = false

    fun open() { connected = true }

    fun send(data: String) {
        check(connected) { "cannot send: connection is not open" }
        println("sending: $data")
    }
}
// Connection().send("hi")   // throws: "cannot send: connection is not open"
error: unconditional failure error
// error() always throws IllegalStateException โ€” no condition to check,
// used to mark a branch that should be structurally unreachable
fun describe(status: String): String = when (status) {
    "active"   -> "Active"
    "inactive" -> "Inactive"
    else        -> error("unknown status: $status")
}

// error()'s return type is Nothing, so it type-checks in any position,
// exactly like return/throw fitting on the right side of ?: (see Null Safety)
๐Ÿ’ก
The choice between the three is about whose fault the failure is, not just what exception type comes out: require says "the caller passed something invalid," check says "this object's own state doesn't support this operation right now," and error says "we should never have gotten here at all." Picking the right one makes the failure message meaningful to whoever reads it later, which matters more than it sounds like when debugging a production incident.
๐Ÿ“ฆ

Result & runCatching

Result<T> wraps either a successful value or a captured exception in a single return type, making failure part of a function's declared signature instead of an invisible throws that callers might forget to handle. runCatching is the bridge from ordinary throwing code into that world โ€” it runs a block and converts any thrown exception into a Result.failure instead of letting it propagate.

runCatching and handling the Result runCatching
fun fetchConfig(path: String): String {
    if (!File(path).exists()) throw java.io.FileNotFoundException(path)
    return File(path).readText()
}

// runCatching converts a thrown exception into Result.failure
val result: Result<String> = runCatching { fetchConfig("config.json") }

// getOrDefault / getOrElse: unwrap with a fallback
val config = result.getOrDefault("{}")

// getOrNull / exceptionOrNull: unwrap into a nullable pair of outcomes
result.getOrNull()?.let { println("loaded: $it") }
result.exceptionOrNull()?.let { println("failed: ${it.message}") }

// fold: handle both branches in one call, no nullable checks needed
val message = result.fold(
    onSuccess = { "Config: $it" },
    onFailure = { "Error: ${it.message}" }
)
โš ๏ธ
runCatching catches Throwable, not just Exception โ€” that includes serious JVM errors like OutOfMemoryError that generally shouldn't be silently wrapped into a Result and handled like an ordinary business-logic failure. It also blindly catches CancellationException inside a coroutine, which breaks cooperative cancellation the same way a bare catch (e: Exception) does (see Coroutines) โ€” avoid wrapping suspending coroutine code in runCatching without re-throwing cancellation.
๐Ÿ’ก
Result<T> shines at API boundaries where you want callers to explicitly acknowledge that an operation can fail โ€” a network call, a parse, a validation step โ€” without forcing every caller into a try/catch. For simple internal logic where a thrown exception is genuinely exceptional and expected to propagate, plain exceptions are still the more direct tool.
๐Ÿ—

Custom Exceptions

A custom exception is an ordinary class extending Exception (or a more specific built-in subtype), and because Kotlin classes can hold typed properties directly in their primary constructor, a custom exception can carry structured context โ€” not just a message string โ€” that callers can pattern-match on with when or a specific catch clause.

A domain-specific exception with structured data Custom exception
class InsufficientFundsException(
    val requested: Int,
    val available: Int
) : Exception("requested $requested, only $available available")

fun withdraw(balance: Int, amount: Int): Int {
    if (amount > balance) {
        throw InsufficientFundsException(requested = amount, available = balance)
    }
    return balance - amount
}

try {
    withdraw(100, 150)
} catch (e: InsufficientFundsException) {
    // The caller gets structured fields, not just a formatted message
    println("short by ${e.requested - e.available}")
}
๐Ÿ’ก
Build a small hierarchy of custom exceptions when a module has several distinct failure modes callers might want to handle differently โ€” a common base class lets a caller catch broadly with one clause, while structured subclasses let them catch narrowly and inspect specific fields. For a single, simple failure mode, extending IllegalArgumentException/IllegalStateException directly, or just reaching for require/check, is often enough โ€” don't build a hierarchy before you have more than one failure mode to distinguish.
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Try as an expressionval x = try { ... } catch (e: E) { ... }Evaluates to whichever branch ran
Validate an argumentrequire(cond) { "message" }Throws IllegalArgumentException
Validate internal statecheck(cond) { "message" }Throws IllegalStateException
Unreachable brancherror("message")Always throws; return type is Nothing
Nullable parses.toIntOrNull()Prefer over try/catch for simple parsing
Wrap a throwing callrunCatching { ... }Catches Throwable โ€” avoid around suspend/coroutine code
Unwrap with defaultresult.getOrDefault(fallback)โ€”
Unwrap to nullableresult.getOrNull() / exceptionOrNull()Exactly one is non-null
Handle both branchesresult.fold(onSuccess, onFailure)No nullable checks needed
Custom exceptionclass E(...) : Exception("msg")Constructor properties carry structured context