Functional Error Handling in Kotlin: runCatching and the Result Type

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 just Exception. This means it will catch Error subclasses like OutOfMemoryError. For most app code this is fine, but be aware of it in library code.
  • Coroutines and cancellation. As mentioned above, if you use recover or getOrElse, always check for CancellationException and rethrow it. The recoverCatching variant 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 throw or check/require is still cleaner and more communicative than a Result chain.

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.

Scroll to Top