Kotlin Error Handling — No Checked Exceptions, try as an Expression, and Result
Why Kotlin deliberately abolished Java's checked exceptions, how try and throw become expressions in the type system, the require/check/error preconditions, and functional error handling with runCatching and Result.
The checked-exceptions decision
Java has two kinds of exceptions. Unchecked ones (like NullPointerException) can be thrown anywhere. Checked ones (like IOException) must be declared in a method's throws clause and handled by every caller, or the code will not compile. Checked exceptions were a bold 1990s experiment in forcing programmers to confront failure. Kotlin looked at three decades of evidence and decided they had failed.
In Kotlin, every exception is unchecked. There is no throws clause, and the compiler never forces you to catch anything. This was a deliberate, somewhat controversial choice, and the Kotlin team has explained their reasoning at length.
The problem with checked exceptions, as observed in large Java codebases, is that they do not scale. In small examples they look responsible. At scale they produce two antipatterns. The first is the empty catch block — developers, forced to handle an exception they cannot meaningfully recover from right there, simply swallow it: catch (e: IOException) { }. That is strictly worse than not catching at all, because it hides the failure. The second is "exception pollution" — a low-level throws IOException ripples up through every intermediate method signature, coupling layers that should not know about each other.
// Java forces this; Kotlin does not
// The infamous swallowed exception:
try {
readFile()
} catch (e: IOException) {
// do nothing — the failure vanishes silently
}
Notably, C# made the same call years earlier, and influential voices — including Java luminaries — came to agree the feature was a net negative. Kotlin's stance is that error handling is a real responsibility, but the compiler mandating where it happens does more harm than good. You are trusted to handle errors at the layer where you can actually do something useful about them.
try and throw are expressions
In Kotlin, almost everything is an expression — it produces a value. This applies to try and even throw, which unlocks concise patterns impossible in Java.
A try/catch evaluates to a value: the value of the try block if it succeeds, or the value of the matching catch block if it throws.
val number: Int = try {
input.toInt()
} catch (e: NumberFormatException) {
0 // the value of the whole try/catch when parsing fails
}
Even throw is an expression — and its type is Nothing, the bottom type from the Type System episode. Because Nothing is a subtype of every type, a throw can appear anywhere a value is expected, which is exactly what makes the Elvis-throw idiom type-check:
val name = user.name ?: throw IllegalArgumentException("name required")
// throw has type Nothing, so the whole expression's type is the
// non-null type of user.name — name is a non-null String here.
This is a recurring theme in Kotlin's design: by making control-flow constructs into expressions and giving "never returns" a real type (Nothing), the language lets error handling compose naturally with everything else, instead of being a special statement-only corner of the syntax.
Preconditions: require, check, and error
Most exceptions you throw deliberately fall into two buckets: "the caller gave me bad arguments" and "my object is in a state where this should not happen." Kotlin's standard library provides three precondition functions that make these intentions explicit and concise.
fun withdraw(amount: Int) {
require(amount > 0) { "amount must be positive, was $amount" }
check(balance >= amount) { "insufficient funds" }
balance -= amount
}
require validates arguments and throws IllegalArgumentException if the condition is false — use it at the top of a function to reject bad input (a "fail fast" guard). check validates state and throws IllegalStateException — use it when an operation is only valid in certain states. error(message) simply throws IllegalStateException unconditionally, and because it returns Nothing, it works in expression position (a common else -> error("unreachable") in a when).
The message is a lambda, not a string — and that lambda is only evaluated when the check actually fails. So you can build a rich, interpolated error message without paying its cost on the (overwhelmingly common) success path. There is also
requireNotNull and checkNotNull, which both validate and smart-cast the value to non-null, folding a null check and an error into one line.
Functional errors: runCatching and Result
Sometimes you want to treat a failure as a value to pass around and transform, rather than a control-flow jump to catch. Kotlin's standard library offers Result<T> — a type that holds either a success value or a failure (a Throwable) — and runCatching, which executes a block and wraps the outcome in a Result.
val result: Result<Config> = runCatching { loadConfig() }
val config = result
.map { it.validated() } // transform the success value
.getOrElse { Config.DEFAULT } // supply a fallback on failure
// Or handle both branches explicitly:
result.fold(
onSuccess = { render(it) },
onFailure = { log(it) },
)
This style shines for pipelines where you want to keep flowing and decide how to handle failure at the end — getOrNull, getOrDefault, recover, and mapCatching give you a small algebra of outcomes.
The coroutine caveat. There is one trap that ties back to the Concurrency episode: a blanket
runCatching (or catch (e: Exception)) inside a coroutine will also catch CancellationException — the very exception that implements cancellation — and silently break it. In coroutine code, either avoid swallowing everything or explicitly rethrow CancellationException. Result is a sharp tool; like the !! operator, it is excellent when used deliberately and dangerous when used to paper over failures.
Cleanup: finally and use
When something must run whether or not an exception is thrown — releasing a lock, closing a connection — Kotlin has the familiar finally block. But for the common case of "open a resource, use it, always close it," there is something cleaner.
Java introduced try-with-resources in Java 7 with special syntax. Kotlin needed no new syntax — it expresses the same idea as an ordinary extension function, use, available on anything that implements Closeable/AutoCloseable:
// 'use' closes the resource automatically, even if the block throws
File("data.txt").bufferedReader().use { reader ->
println(reader.readText())
} // reader.close() is guaranteed here
The use function takes a lambda (the Functions episode again), runs it, and closes the resource in a finally internally — propagating your block's exception if there was one, or any close exception otherwise. It is a small but telling example of Kotlin's philosophy: where Java added a language feature, Kotlin reached for a library function built on lambdas and extensions. The same building blocks, composed differently, often remove the need for new syntax entirely.
AI prompts — error handling
Use these prompts with an AI assistant to go deeper on the ideas in this episode.
Prompt 1 — exceptions vs Result:
For this function, should failures be signalled with exceptions,
a nullable return, or a Result?
[paste function and how callers use it]
Explain the trade-offs and rewrite it the idiomatic way.
Prompt 2 — add preconditions:
Add fail-fast preconditions to this function using require/check/
requireNotNull where appropriate:
[paste function]
Explain which precondition fits each invariant and why.
Prompt 3 — runCatching pipeline:
Refactor this nested try/catch into a runCatching pipeline using
map, recover, getOrElse, or fold:
[paste code]
And flag any coroutine code where CancellationException must be
rethrown rather than swallowed.
Prompt 4 — resource cleanup:
Review this code that opens files/streams/connections:
[paste code]
Convert manual try/finally close logic to the use { } function, and
point out any resource that isn't guaranteed to be closed.
Kotlin Error Handling — Quiz
7 questions
Seven questions on checked exceptions, try/throw as expressions, preconditions, and Result-based error handling.