Clean Error Handling in Kotlin with runCatching and Result
Why traditional try‑catch falls short
Most of us start with a try { … } catch (e: Exception) { … } block because it feels familiar. The problem appears when the happy path and the error path diverge in type: you end up returning null, throwing a custom exception, or stuffing a sentinel value into a variable that later forces a !! somewhere downstream. That pattern spreads brittleness across the codebase and makes unit testing a chore.
Tip: If you find yourself writing return null in a catch block just to satisfy the compiler, you’re likely missing a better abstraction.Enter runCatching and Result
Kotlin’s standard library ships a tiny but powerful duo: runCatching and the Result<T> class. runCatching executes a lambda and wraps the outcome — either a value or a thrown exception — into a Result. The API is deliberately minimal, yet it gives you functional composition tools (map, flatMap, fold, onSuccess, onFailure) that keep the happy path linear.
import kotlin.Result
import kotlin.runCatching
fun fetchUser(id: String): Result = runCatching {
// network call that may throw
apiClient.getUser(id)
} The function now returns Result<User> — no null, no checked exceptions, just a value you can pattern‑match on.
Real‑world example: parsing API responses
Imagine a service that calls a REST endpoint, receives JSON, and maps it to a domain model. The network layer can throw IOException, the JSON parser can throw JsonParsingException, and your business rules may reject malformed data. With runCatching you can isolate each failure domain without nesting try‑catch blocks.
data class User(val id: String, val name: String, val email: String)
sealed class UserError {
data class Network(cause: Throwable) : UserError()
data class Parsing(cause: Throwable) : UserError()
object Validation : UserError()
}
fun fetchUserSafe(id: String): Result = runCatching {
val response = httpClient.get("https://api.example.com/users/$id\)) // may throw IOException
val json = response.body?.string() ?: throw IllegalStateException("Empty body\)) // may throw
val userDto = Json.parse(UserDto.serializer(), json) // may throw JsonParsingException
if (!userDto.email.contains("@\)) throw IllegalArgumentException("Invalid email\)) // validation
User(userDto.id, userDto.name, userDto.email)
}.mapFailure { throwable ->
when (throwable) {
is IOException -> UserError.Network(throwable)
is JsonParsingException -> UserError.Parsing(throwable)
is IllegalArgumentException -> UserError.Validation
else -> throwable // rethrow unexpected
}
} Notice the mapFailure call: it translates low‑level exceptions into a typed error hierarchy without breaking the Result chain. Callers can now decide how to handle each UserError variant via fold or a when on Result.
Composing results with map and flatMap
Because Result is a monad‑like container, you can chain dependent operations cleanly:
fun enrichUserProfile(userId: String): Result =
fetchUserSafe(userId)
.flatMap { user -> fetchPreferencesSafe(user.id) }
.map { prefs -> Profile(it.user, prefs) }
.onFailure { log.warn("Profile enrichment failed", it) } flatMapunwraps a successfulResultand feeds the value into the next fallible call.maptransforms the success value without affecting the error.onFailurelets you log or metric‑collect without swallowing the error.
Contrast this with a cascade of nested try‑catch blocks where each level must re‑throw or convert the exception manually.
When to reach for sealed classes instead
Result shines when the error is truly exceptional — network glitches, parsing bugs, invariant violations. If your domain models expected outcomes (e.g., “user not found”, “insufficient permissions”), a sealed class hierarchy is clearer because the caller must handle every branch.
sealed class AuthResult {
data class Success(val token: String) : AuthResult()
data class Failure(val reason: FailureReason) : AuthResult()
enum class FailureReason { INVALID_CREDENTIALS, ACCOUNT_LOCKED, MFA_REQUIRED }
}
fun login(credentials: Credentials): AuthResult = runCatching {
authService.authenticate(credentials)
}.fold(
onSuccess = { AuthResult.Success(it) },
onFailure = { AuthResult.Failure(mapAuthError(it)) }
)Here the sealed result makes the contract explicit: callers cannot ignore Failure because the when expression is exhaustive.
Key takeaways
- Use
runCatchingto turn any throwing block into aResultwithout boilerplate. - Prefer
Resultfor unexpected failures; it gives you composablemap/flatMapand keeps the happy path readable. - Map failures early with
mapFailureso downstream code works with domain‑specific error types. - Reserve sealed classes for expected, business‑level outcomes that callers must handle exhaustively.
Adopting this pattern has cut our error‑handling lines by roughly 30 % and, more importantly, eliminated the dreaded NullPointerException that used to surface three call‑sites away from the original catch block. Give it a spin on the next service you refactor — you’ll feel the difference the first time you chain two fallible calls with a single flatMap.