Unchecked and Unbothered: How Kotlin Rethinks Java's Exception Handling

Kotlin drops checked exceptions entirely — why, what changes day to day, try/catch as an expression, use and the precondition helpers, runCatching and Result, and the @Throws you need at the Java boundary.

If you’ve spent time in a Java codebase, you’ve caught yourself writing throws Exception to make the compiler stop complaining, or swallowing errors in an empty catch and hoping. Kotlin runs on the same JVM, uses the same Throwable hierarchy, and shares the try/catch/finally/throw keywords — but its approach is fundamentally different, and the difference reveals a quietly clever piece of how the JVM actually works. Everything here was run against Kotlin 2.4.10.

This chapter follows generics. First the surface, then the wiring.

The headline: no checked exceptions

Kotlin has no checked exceptions. Every exception is effectively unchecked, so a function that can fail carries no throws clause:

fun readFile(path: String): String =
    Files.readString(Paths.get(path))     // throws IOException in Java; no clause needed here

The reasoning is the argument Anders Hejlsberg and others made years ago: checked exceptions don’t scale. They tend to produce either throws Exception everywhere (defeating the purpose) or empty catch blocks (worse), and they break down badly with the lambdas and higher-order functions Kotlin leans on — a forEach whose body can throw IOException can’t propagate it through a (T) -> Unit type. The trade-off is real: you lose the compiler nudge to handle I/O and JDBC failures, and you’re back to documentation and discipline for which failures a function can produce.

try/catch is an expression

The syntactic feature you’ll use most: try yields a value, so you can assign its result directly instead of hoisting a mutable variable the way Java forces:

val number: Int = try {
    input.toInt()
} catch (e: NumberFormatException) {
    0
}

This composes with Kotlin’s general “everything is an expression” grain, and it keeps the variable a val.

Resource management with use

Java’s try-with-resources becomes the use extension function on any Closeable/AutoCloseable:

BufferedReader(FileReader(path)).use { reader ->
    reader.readLine()
}

use calls close() on normal completion and on exception, exactly like try-with-resources — but it’s a library function, not language syntax, so you can write your own use-shaped helpers for any acquire/release pattern.

Nothing, and the precondition helpers

A function that always throws returns Nothing, the bottom type, which lets it slot into an Elvis and drive flow analysis:

val name: String = user.name ?: error("name required")   // error() returns Nothing

The standard library wraps the common throws in precondition helpers, each taking a lazy message lambda so the string is only built on failure:

require(age >= 0) { "age must be non-negative, was $age" }   // IllegalArgumentException
check(state == State.READY) { "wrong state: $state" }        // IllegalStateException
val name = requireNotNull(user.name) { "name was null" }      // returns the non-null value
error("unreachable")                                          // IllegalStateException, returns Nothing

require is for bad arguments, check for bad state — the distinction is which exception the caller sees, and it’s worth honoring.

runCatching and Result

For folding a computation’s success-or-failure into a value, runCatching returns a Result<T>:

val result: Result<User> = runCatching { fetchUser(id) }
result
    .onSuccess { user -> println(user.name) }
    .onFailure { e -> log.error("failed", e) }

Closer in spirit to Rust’s Result or Scala’s Try, and Java has nothing built in like it. Use it with care: runCatching catches all Throwable, including the ones you shouldn’t swallow like CancellationException in coroutines and OutOfMemoryError. It shines at API boundaries where you want to convert any failure into a value; for ordinary control flow, plain try/catch is still idiomatic.

Why “no checked exceptions” even works

If Kotlin doesn’t enforce checked exceptions, what happens when Kotlin calls a Java method declared throws IOException? Nothing special: the exception just propagates. There’s no hidden handler, no wrapping. The key fact is that the JVM has no concept of checked exceptions — at the bytecode level IOException and RuntimeException unwind the stack identically. “Checked-ness” lives only in javac, which reads a method’s throws metadata and refuses to compile callers that don’t catch or re-declare it. It’s a compile-time convention over a runtime that doesn’t care.

Kotlin’s compiler reads that same metadata and simply doesn’t enforce it. It emits an ordinary call, adds no throws attribute to the calling function, and lets the exception flow up the stack until something catches it — or the thread dies with a stack trace, exactly as an uncaught RuntimeException would in Java. You can still catch a Java checked exception when you want to; you just aren’t forced to.

The reverse direction needs @Throws

The convention reappears the moment Java calls Kotlin. Java’s compiler still enforces checked exceptions from bytecode metadata, so if your Kotlin function throws IOException and you want a Java caller to catch it without an “unreachable catch block” error, you must add the metadata back with @Throws:

@Throws(IOException::class)
fun readConfig(): String = Files.readString(Paths.get("config.json"))

That emits the Exceptions attribute Java’s compiler wants — the one annotation you need precisely because Kotlin has no checked exceptions of its own to declare. The interop chapter covers it alongside the other @Jvm hints.

Domain errors as types, not throws

The deeper shift is what you do instead. For failures a caller should reasonably handle — a payment declined, a user not found — many Kotlin codebases model the outcome as a sealed type rather than an exception, which brings back a compiler guarantee that checked exceptions were reaching for and missing:

sealed interface FetchResult {
    data class Success(val user: User) : FetchResult
    data class Error(val message: String) : FetchResult
}

An exhaustive when over that result forces every caller to handle the error case, at compile time, with none of the throws-clause noise. Exceptions become the “didn’t expect this” channel, not a control-flow mechanism.

Final thoughts

Kotlin’s handling of checked exceptions is to opt out of a Java-compiler convention the JVM never enforced, and let unchecked exceptions fly for genuinely exceptional cases. The mental shift for a Java engineer is to stop asking which exceptions to declare and start asking which failures are part of this function’s contract — model those as require/check or sealed results — versus which are truly exceptional and should propagate. The compiler won’t push you anymore, which turns out to force clearer thinking about which errors actually matter. The one annotation to remember is @Throws, and only at the Java boundary.

Next: calling Java from Kotlin and back — including that @Throws in its proper context, among the handful of @Jvm hints a mixed codebase needs.

Practice: reinforce this with the companion workbook — short, click-to-reveal problems.

Comments