Why Traditional try‑catch Can Feel Limiting

When I started migrating a monolithic Android app to a more modular architecture, I found myself juggling dozens of try‑catch blocks. Each one buried a piece of business logic, made the call chain hard to follow, and forced me to decide whether to propagate exceptions or swallow them locally. The imperative style also made unit testing cumbersome because I had to mock exceptions at every level.

That’s when Kotlin’s functional error‑handling primitives—`runCatching` and the sealed `Result` type—started appearing in my codebase. They let me treat errors as data, keep the flow linear, and compose operations without sacrificing safety.

Enter runCatching and Result

`runCatching` is a top‑level function that executes a block of code and returns either a `Result.success(value)` or `Result.failure(exception)`. It does not throw; it captures the outcome as a value. The `Result` type is a simple sealed class:


sealed class Result {
    data class Success<out T>(val data: T) : Result<T>
    data class Failure(val error: Throwable) : Result<Nothing>
}

Because `Result` is sealed, the compiler forces you to handle both success and failure branches, which eliminates the “oops, I forgot to check for an exception” scenario.

The beauty of this approach is twofold:

  • Composability – you can chain `map`, `flatMap`, or `fold` without nesting try‑catch blocks.
  • Testing friendliness – you can feed a `Result` directly into a mock, verify its shape, and avoid exception‑based assertions.

Let’s see how this looks in practice.

Real‑World Example: Fetching User Profile

Imagine a screen that displays a user’s profile picture, name, and email. The data comes from three distinct sources: a local cache, a remote API, and a device contact provider. I want to attempt each source in order, falling back gracefully if one fails.

Before `runCatching`, the code looked like this:


fun loadProfile(): UserProfile? {
    // 1. Try cache
    val cached = cache.getUser()
    if (cached != null) return cached

    try {
        // 2. Try remote API
        val remote = api.getUser()
        // 3. Try contacts as final fallback
        val contact = contacts.findCurrentUser()
        return UserProfile(remote.name, remote.email, contact?.photo)
    } catch (e: NetworkException) {
        log.warn("Remote fetch failed", e)
    } catch (e: ContactException) {
        log.warn("Contact lookup failed", e)
    }
    return null
}

The flow is hard to reason about, and the null return forces the UI layer to handle a possible `null` state.

With `runCatching`, I can express the same logic as a pipeline of `Result`s:


fun loadProfile(): Result<UserProfile> {
    // Start from cache – if missing, fall back to remote
    val cacheResult = runCatching { cache.getUser() }
    val remoteResult = cacheResult.getOrElse {
        runCatching { api.getUser() }
    }
    val contactResult = remoteResult.getOrElse {
        runCatching { contacts.findCurrentUser() }
    }

    // Combine the three sources into a single profile
    return contactResult.fold(
        onSuccess = { contact ->
            // If we have a remote result, use it; otherwise create a minimal profile
            remoteResult.map { remote ->
                UserProfile(remote.name, remote.email, contact?.photo)
            }.getOrElse {
                // No remote data, only contact info
                Result.success(UserProfile(contact?.name ?: "", contact?.email ?: "", contact?.photo))
            }
        },
        onFailure = { contactError ->
            // Even contact lookup failed – propagate the first non‑cache error
            remoteResult.getOrElse { cacheResult.getOrThrow() }
        }
    )
}

Each step is a `Result`. If the cache is empty, we attempt the remote API; if that fails, we try contacts. The `fold` at the end gives us a single `Result` that the UI can observe safely.

Tip: `runCatching` is perfect for any operation that can fail but should not abort the whole flow. It turns exceptions into data, keeping your call stack clean.

The UI layer can now consume the result like this:


viewModel.profileFlow.onEach { result ->
    when (result) {
        is Result.Success -> {
            renderProfile(result.data)
        }
        is Result.Failure -> {
            showError(result.error.localizedMessage)
        }
    }
}.launchIn(viewModelScope)

No null checks, no crash‑reporting hacks—just a straightforward pattern.

Chaining Operations with fold, map, and flatMap

`Result` provides several extension functions that mirror the `Option` monad in other languages:

  • map – transform the success value, preserving failure.
  • flatMap – chain operations that also return `Result`.
  • getOrElse – unwrap a success or provide a fallback value.
  • getOrThrow – re‑throw the encapsulated exception.

Using `flatMap` is especially handy when you have a series of dependent API calls. For example, after fetching a user, you might need to fetch that user’s settings:


fun loadUserWithSettings(userId: String): Result<UserWithSettings> =
    runCatching { api.getUser(userId) }
        .flatMap { user ->
            runCatching { settingsApi.getSettings(user.id) }
                .map { settings -> UserWithSettings(user, settings) }
        }

If either call fails, the whole chain stops at the first failure, and you get a `Result.Failure` containing the original exception. This eliminates the need to manually check for `null` after each step.

Best Practices and Common Pitfalls

When I first adopted this style, I fell into a couple of traps:

  1. Over‑nesting `runCatching`. It’s tempting to wrap every potentially failing line, but doing so can obscure the flow. Use it for distinct logical steps rather than for every `let` or `apply`.
  2. Ignoring `Result` in UI code. The temptation to call `getOrThrow` to “just get the value” defeats the purpose. Keep the `Result` alive as long as you need to handle errors gracefully.
  3. Mixing exception‑throwing APIs with `Result`. If you have a third‑party library that throws, wrap its calls with `runCatching` at the boundary, then convert the `Result` into your own internal model.

A solid rule of thumb: **Treat `runCatching` as a boundary converter**. Inside your domain logic, work with `Result` values; only at the edges (e.g., Android SDK calls, network layer) do you translate between exceptions and `Result`.

Another practical tip is to use `apply` or `let` together with `runCatching` for fluent DSLs. For instance:


fun buildProfile(): Result<UserProfile> = runCatching {
    UserProfile(
        name = api.getUser().apply { validate() }.name,
        email = contacts.findCurrentUser()?.email ?: ""
    )
}

Here `apply` runs a side‑effect (validation) while still letting the outer `runCatching` capture any exception from `api.getUser()`.

Conclusion

Integrating `runCatching` and `Result` into my daily workflow has transformed how I think about errors. Instead of a cascade of try‑catch blocks, I now compose failures as first‑class values, making the code easier to reason about, test, and maintain. Whether you’re fetching data from multiple sources, chaining API calls, or simply want a safer way to call into Java libraries, Kotlin’s functional error‑handling primitives give you a clean, expressive alternative.

Start small: wrap a single external call with `runCatching`, let the `Result` bubble up, and observe how the error handling becomes more declarative. Over time, you’ll find the pattern naturally expanding to cover larger chunks of your domain logic.

Give it a try in your next feature, and you’ll likely wonder how you ever lived without it.