skip to content

You are building a Result transformation pipeline (map/mapCatching/recover/fold) inside suspend functions. What correctness and design pitfalls should you guard against?

level: principalimportance: nice to knowfreq 25%

answer

  1. *Catching catch Throwable -> re-throw CancellationException
  2. Also swallows OOM/StackOverflow — consider re-throwing Error
  3. Result failure is always Throwable; no typed error channel
  4. fold for exhaustiveness; unwrap at API boundaries
  5. Result is a value class but boxes in generics/collections/nullable

basics

~10 s

The big one: mapCatching/recoverCatching catch all Throwable, including CancellationException, so a Result pipeline can silently swallow coroutine cancellation. Always re-throw CancellationException. Also avoid hiding domain errors behind a generic Result that loses type information.

solid answer

~50 s

Result's *Catching operators (mapCatching, recoverCatching, and runCatching itself) catch Throwable. Inside coroutines that includes CancellationException, which the runtime throws to cooperatively cancel; catching and folding it into a failure Result breaks structured concurrency — the coroutine won't actually cancel and parent scopes get a spurious failure. The standard fix is to re-throw CancellationException before handling other errors. Second, these operators also catch fatal errors (OutOfMemoryError, StackOverflowError) you usually shouldn't swallow. Third, Result is a value class with allocation quirks (boxing when nullable/used as generic), and it carries only a Throwable — it can't model typed domain errors, so deep pipelines often want a sealed Either-like type instead. Fourth, fold gives exhaustiveness; relying on isSuccess + getOrThrow loses that guarantee. Decide at the boundary whether to keep Result internal and unwrap (fold/getOrThrow) before crossing module/API edges.

code

kotlin · 8 lines
kotlin
suspend fun <T> coRunCatching(block: suspend () -> T): Result<T> =
    try {
        Result.success(block())
    } catch (c: CancellationException) {
        throw c                       // preserve structured concurrency
    } catch (e: Throwable) {
        Result.failure(e)
    }

go deeper

for a junior

Likely unaware of the cancellation hazard; can use the operators but not reason about coroutine correctness.

for a middle

Knows *Catching catches Throwable and that cancellation needs care, but may not articulate the boxing or typed-error tradeoffs.

for a senior

Implements a cancellation-safe runCatching, prefers fold for exhaustiveness, and unwraps Result at boundaries.

for a principal

Sets pipeline-wide conventions (cancellation, fatal errors, typed-error vs Result), weighs value-class costs, and defines where Result may cross module boundaries.

## Pitfall 1 — swallowing CancellationException (the critical one) `runCatching`, `mapCatching`, and `recoverCatching` all catch `Throwable`. In coroutines, cancellation is signalled by throwing `CancellationException`; catching it as a "failure" prevents the coroutine from actually cancelling and corrupts structured concurrency. The defensive pattern: ```kotlin suspend fun load(): Result<Data> = runCatching { fetch() } .recoverCatching { e -> if (e is CancellationException) throw e // never swallow cancellation loadFromCache() } ``` A common helper is a `coRunCatching`/`runCatchingCancellable` wrapper that re-throws `CancellationException` and catches the rest. The stdlib `runCatching` deliberately does **not** do this for you. ## Pitfall 2 — swallowing fatal errors Catching `Throwable` also captures `OutOfMemoryError`, `StackOverflowError`, etc. These are rarely recoverable and hiding them in a `Result.failure` masks process-level problems. Consider re-throwing `Error`/non-`Exception` throwables too. ## Pitfall 3 — Result can't model typed domain errors `Result<T>`'s failure side is always a `Throwable`; there is no `Result<T, E>`. If your domain has rich, expected errors (`NotFound`, `Forbidden`, `RateLimited`), a sealed class / Arrow `Either` / custom `Outcome<T, E>` expresses intent and exhaustiveness better. `fold` then becomes a `when` over a sealed hierarchy with compiler-checked exhaustiveness. ## Pitfall 4 — exhaustiveness and boundaries - Prefer `fold(onSuccess, onFailure)` over `if (isSuccess) getOrThrow() else handle(exceptionOrNull()!!)` — `fold` is total and avoids `!!`. - Decide the **boundary**: keep `Result` as an internal composition detail and **unwrap** it (`fold`, `getOrThrow`) before returning across a public `*Api` or controller edge, so callers aren't forced into your error representation. ## Pitfall 5 — value-class / boxing surprises `Result<T>` is an inline `value class`. It avoids allocation in many cases but **boxes** when stored as a nullable, used as a generic type argument, or placed in collections — so a `List<Result<T>>` is not free. Don't reach for Result in hot allocation paths assuming zero cost. ## Putting it together ```kotlin suspend fun handle(id: Id): HttpResponse = runCatching { service.load(id) } .recoverCatching { e -> if (e is CancellationException) throw e service.loadStale(id) } .fold( onSuccess = { ok(it) }, onFailure = { e -> errorResponse(e) } // unwrap at the boundary ) ```

  • Why must a Result pipeline re-throw CancellationException in coroutines?
    Cancellation is delivered as a thrown CancellationException; swallowing it into a failure Result stops the coroutine from cancelling and breaks structured concurrency, leaving zombie work and spurious failures.
  • When would you choose a sealed Either-style type over Result<T>?
    When errors are expected domain outcomes with distinct types you want to handle exhaustively; Result only carries a Throwable and cannot express a typed error channel.

A *Catching operator is a fishing net with holes too small — it scoops up the fish you wanted (real errors) but also the lifeguard's whistle (cancellation) and the boat's distress flare (OOM); you must throw those back.

saying these in an interview costs you the question

  • Believing runCatching/mapCatching re-throw CancellationException automatically
  • Treating Result as zero-cost in collections/generics
  • Using Result to model rich domain errors that need types
  • Catching Throwable and silently dropping OOM/StackOverflow
  • Leaking Result across public API boundaries without a deliberate decision

context