Mastering Exception Handling in Kotlin: Beyond the Basics

 Learn how to write idiomatic error-handling code, avoid the Coroutine cancellation trap, and choose the right tool for your Kotlin projects.

Mastering Exception Handling in Kotlin: Beyond the Basics

Exception handling is often seen as a necessary evil — a chore we perform to prevent our applications from crashing. But in Kotlin, with its expressive syntax and powerful standard library, error handling can be elegant, readable, and even functional.

This post explores Kotlin’s two primary approaches: the classic try-catch expression and the functional runCatching function.

The Classic: try-catch as an Expression

In Kotlin, try is an expression, not just a statement. This means it returns a value, allowing you to initialize variables directly from an error-prone block.

fun safelyParseInteger(input: String): Int? {
return try {
input.toInt() // Success value
} catch (e: NumberFormatException) {
println("Oops! '$input' is not a valid integer.")
null // Failure value
} finally {
// Always runs, but cannot override the return value
println("Parse attempt complete.")
}
}

When to use try-catch:

  • Granular Control: When you need to catch specific exceptions (like IOException vs SQLException) and react differently to each.
  • Explicit Error Logic: When the failure is a “loud” event that requires immediate logging or complex recovery.
  • Resource Management: When you need the finally block to close sockets, database connections, or file streams.

The Functional Approach: runCatching

Kotlin’s runCatching wraps a block of code and returns a Result<T> object. This object encapsulates either a Success or a Failure, transforming exceptions into data that you can manipulate.

fun fetchUsername(id: Int): Result<String> {
return runCatching {
if (id < 0) throw IllegalArgumentException("ID must be positive")
"User_$id"
}
}

// Usage with functional chaining
fetchUsername(-1)
.onSuccess { name -> println("Hello, $name") }
.onFailure { e -> println("Error: ${e.message}") }
.getOrDefault("Guest")

⚠️ The “Invisible” Danger: Coroutines & Cancellation

While runCatching is elegant, it has a significant catch when used with Coroutines.

In Kotlin, coroutines are cancelled by throwing a CancellationException. Because runCatching catches all Throwable types, it will accidentally catch the cancellation signal. This "swallows" the cancellation, preventing the coroutine from stopping properly and potentially leading to memory leaks or "zombie" tasks.

The Fix: Explicitly rethrow cancellation if you use runCatching in a suspend function.

suspend fun loadData() = runCatching {
apiCall()
}.onFailure { if (it is CancellationException) throw it }

When to Choose Which?

The choice isn’t just about syntax; it’s about intent.

  1. Use try-catch for Domain Logic Errors. If a user enters an invalid password, that's a specific state you need to handle with high visibility.
  2. Use runCatching for Side Effects. If you're calling an external logger or a non-critical analytics hook, runCatching keeps the "happy path" of your main code clean.

A Note on JVM Errors

Be mindful that runCatching also catches all Error types, such as OutOfMemoryError. Generally, your application should not try to recover from these, as the JVM state is likely unstable. Direct try-catch blocks allow you to target Exception specifically, leaving critical Errors to bubble up and stop the process as they should.

Frequently Asked Questions (FAQs)

Does runCatching have a performance penalty?

Technically, very little. It is an inline function, and Result is an inline value class. The "cost" is almost identical to a standard try-catch. The real performance hit comes from the creation of the Exception's stack trace, not the wrapper used to catch it.

Can I return Result from my Public API?

While possible, it's often discouraged for library authors. Result has specific design constraints (it's an inline class with a restricted constructor). For public APIs, consider using a Sealed Class (e.g., sealed class NetworkResult) to provide more domain-specific, type-safe error states.

Why does finally not change the return value in Kotlin?

In Kotlin, the value of the try or catch block is what is returned. The finally block is strictly for side effects (like closing a file). This prevents a common bug where a finally block accidentally overwrites a successful result with a new return statement.

Join the Conversation!

  • Have you ever run into a bug where runCatching swallowed a CancellationException?
  • Do you prefer the “Result” pattern (Functional) or the “Try-Catch” pattern (Imperative) for your repositories?

Final Takeaway: Kotlin doesn’t replace developer judgment with syntax — it simply gives you better tools to express it.

If you find my content useful, feel free to support my work here. Every contribution is greatly appreciated! 🙏

Comments

Popular posts from this blog

Stop Writing Massive when Statements: Master the State Pattern in Kotlin

Coroutines & Flows: 5 Critical Anti-Patterns That Are Secretly Slowing Down Your Android App

Code Generation vs. Reflection: A Build-Time Reliability Analysis