Exceptions in Kotlin
all unchecked ยท same syntax as JavaJava 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.
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
try-catch evaluates to a valueLike 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.
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
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
precondition helpers ยท fail fastThese 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 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 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() 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)
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> ยท explicit success/failureResult<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.
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
extend Exception ยท carry structured dataA 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.
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}") }
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
Syntax cheat-sheet| Concept | Syntax | Notes |
|---|---|---|
| Try as an expression | val x = try { ... } catch (e: E) { ... } | Evaluates to whichever branch ran |
| Validate an argument | require(cond) { "message" } | Throws IllegalArgumentException |
| Validate internal state | check(cond) { "message" } | Throws IllegalStateException |
| Unreachable branch | error("message") | Always throws; return type is Nothing |
| Nullable parse | s.toIntOrNull() | Prefer over try/catch for simple parsing |
| Wrap a throwing call | runCatching { ... } | Catches Throwable โ avoid around suspend/coroutine code |
| Unwrap with default | result.getOrDefault(fallback) | โ |
| Unwrap to nullable | result.getOrNull() / exceptionOrNull() | Exactly one is non-null |
| Handle both branches | result.fold(onSuccess, onFailure) | No nullable checks needed |
| Custom exception | class E(...) : Exception("msg") | Constructor properties carry structured context |