The Problem With Try-Catch Everywhere
Exception handling in Kotlin (and Java before it) has always had a composability problem. Once you introduce a try-catch block, you break the expression-oriented flow of your code. You can’t easily chain operations, return from them in one line, or pass the “success or failure” result to another function without a lot of boilerplate.
Kotlin’s standard library has a quiet answer to this: runCatching and its companion, the Result type. Together they let you treat errors as values rather than as exceptional control flow — bringing a functional style to error handling without pulling in a third-party library like Arrow.
What Is runCatching?
runCatching executes a block and wraps the outcome in a Result<T>: either a Result.success(value) if the block completes normally, or a Result.failure(exception) if any Throwable is thrown. It never rethrows — it always returns.
val result: Result = runCatching {
"42abc".toInt() // throws NumberFormatException
}
println(result.isSuccess) // false
println(result.isFailure) // true
println(result.exceptionOrNull()) // java.lang.NumberFormatException: For input string: "42abc"
val result2: Result = runCatching { "42".toInt() }
println(result2.getOrNull()) // 42
There’s also a receiver version: someObject.runCatching { someMethod() }, where this inside the block is someObject. This is handy when you want to call a potentially throwing method on an existing object.
Working With the Result Type
The real value isn’t just wrapping — it’s what you can do with the Result afterward. The standard library ships a rich set of extension functions that let you transform, recover, and extract values in a composable way.
Extracting Values
val result = runCatching { fetchUserFromNetwork(userId) }
// Returns value or null
val user: User? = result.getOrNull()
// Returns value or a default
val user: User = result.getOrDefault(User.anonymous())
// Returns value or calls a lambda with the exception
val user: User = result.getOrElse { exception ->
Log.e("UserFetch", "Failed to load user", exception)
User.anonymous()
}
// Returns value or rethrows — use when you've handled what you can
val user: User = result.getOrThrow()
Transforming Success Values
map applies a transform to the success value and returns a new Result. If the original result is a failure, map passes the failure through unchanged:
val nameResult: Result = runCatching { fetchUserFromNetwork(userId) }
.map { user -> user.displayName }
// mapCatching does the same but also catches exceptions thrown inside the transform
val uppercasedName: Result = runCatching { fetchUserFromNetwork(userId) }
.mapCatching { user -> user.displayName.uppercase() }
Recovering From Failures
recover is the mirror image of map — it transforms a failure into a success value, while leaving successes unchanged:
val user: Result = runCatching { fetchUserFromNetwork(userId) }
.recover { exception ->
when (exception) {
is IOException -> User.anonymous() // network error — return anonymous
is CancellationException -> throw exception // always rethrow cancellation!
else -> throw exception // unknown error — let it propagate
}
}
// recoverCatching wraps exceptions thrown inside the recover block
val user2: Result = runCatching { fetchUserFromNetwork(userId) }
.recoverCatching { _ -> fetchUserFromCache(userId) }
Notice the important pattern in the recover example: always rethrow CancellationException. If you’re using Kotlin coroutines, swallowing a CancellationException breaks coroutine cancellation, which leads to subtle and hard-to-debug bugs.
Chaining Operations: A Real Android Example
One of the most compelling uses of runCatching is chaining network + parse + cache operations without nested try-catch blocks:
suspend fun loadArticle(id: String): Article {
return runCatching { api.fetchArticle(id) } // network call
.mapCatching { response -> response.toArticle() } // parse JSON
.onSuccess { article -> cache.save(article) } // side effect on success
.onFailure { e -> analytics.logError("article_load", e) } // side effect on failure
.recoverCatching { cache.load(id) } // fall back to cache
.getOrThrow() // propagate if all else fails
}
Each step in the chain only runs if the previous one succeeded (except recover/recoverCatching, which run on failure). The whole thing reads top-to-bottom like a description of your intent, rather than an inside-out nest of try-catch blocks.
fold: Handling Both Paths
When you want to produce a single value from either the success or failure case, fold is the cleanest option:
val message: String = runCatching { parseConfig(rawJson) }
.fold(
onSuccess = { config -> "Loaded config v${config.version}" },
onFailure = { e -> "Failed to parse config: ${e.message}" }
)
showToast(message)
Things to Watch Out For
A few gotchas worth knowing before you refactor everything to runCatching:
- It catches
Throwable, not justException. This means it will catchErrorsubclasses likeOutOfMemoryError. For most app code this is fine, but be aware of it in library code. - Coroutines and cancellation. As mentioned above, if you use
recoverorgetOrElse, always check forCancellationExceptionand rethrow it. TherecoverCatchingvariant wraps exceptions thrown inside recover, so it’s safer in coroutine contexts. - Don’t overuse it. For truly exceptional situations (programming errors, impossible states) that you don’t expect to recover from, a regular
throworcheck/requireis still cleaner and more communicative than aResultchain.
When runCatching Fits Best
The pattern shines when you’re dealing with expected failures — network errors, file not found, malformed input — and you want to handle them gracefully without breaking your code’s expressive flow. It’s also great for writing one-liners that combine an operation with a default:
// Parse an Int safely
fun String.toIntOrLog(tag: String): Int? =
runCatching { toInt() }.onFailure { Log.w(tag, "Parse failed: $this") }.getOrNull()
// Read a preference that might throw
val timeout = runCatching { prefs.getInt("timeout", 30) }.getOrDefault(30)
Summary
runCatching and Result are standard-library features that most Kotlin developers know exist but underuse. They let you treat errors as values, chain operations without try-catch nesting, and separate the “what went wrong” decision from the “how to recover” decision. If you find yourself writing deeply nested try-catch blocks or juggling nullable results alongside exception handling, give runCatching a try — you might be surprised how much cleaner your error-handling logic becomes.
This post was written by a human with the help of Claude, an AI assistant by Anthropic.
