Error Handling
Exceptions were designed for "exceptional" conditions. In practice, most errors aren't exceptional — network down, input invalid, payment declined. Treating them as exceptions (throw, catch far away, lose context) is how bugs hide. This chapter covers typed errors — the technique used by every serious Kotlin codebase to make errors compile-time obligations.
The problem with exceptions
// Signature lies — says "returns User", may throw anything
suspend fun fetchUser(id: String): User
// Caller doesn't know what can go wrong
viewModelScope.launch {
val user = fetchUser(id) // might throw IOException? HttpException? AuthError?
// Now what?
}
You write a try/catch:
viewModelScope.launch {
try {
val user = fetchUser(id)
_state.update { it.copy(user = user) }
} catch (e: IOException) {
_state.update { it.copy(error = "No connection") }
} catch (e: HttpException) when (e.code()) {
401 -> router.signOut()
else -> _state.update { it.copy(error = "Server error") }
} catch (e: Throwable) {
_state.update { it.copy(error = "Unknown error") }
}
}
Problems:
- Compiler doesn't enforce the catches — a new error type added later silently propagates.
- Domain errors (PaymentDeclined) mix with framework errors (IOException).
- Business logic interleaves with plumbing; logic gets lost.
catch (Throwable)swallowsCancellationException— breaks coroutine cancellation.
Level 1 — Kotlin's Result<T>
The Kotlin stdlib ships a minimal typed-error type:
@Suppress("ResultUnwrap")
suspend fun fetchUser(id: String): Result<User> = runCatching {
api.getUser(id).toDomain()
}
viewModelScope.launch {
fetchUser(id)
.onSuccess { user -> _state.update { it.copy(user = user) } }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
}
Pros: simple, zero dependencies, composes with .map, .flatMap, .fold.
Cons: the error side is Throwable — you can't express typed domain
errors like "PaymentDeclined" or "InvalidEmail" without casting.
Level 2 — Typed domain errors via sealed hierarchies
For real domain logic, define an error algebra:
sealed interface LoginError {
data object InvalidEmail : LoginError
data object WrongPassword : LoginError
data class RateLimited(val retryAfter: Duration) : LoginError
data class Network(val cause: Throwable) : LoginError
data object Unknown : LoginError
}
// The function returns a Result where the error side is typed
sealed interface LoginOutcome {
data class Ok(val session: Session) : LoginOutcome
data class Err(val error: LoginError) : LoginOutcome
}
suspend fun login(email: String, password: String): LoginOutcome = try {
val session = api.login(LoginRequest(email, password)).toSession()
LoginOutcome.Ok(session)
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) when (e.code()) {
401 -> LoginOutcome.Err(LoginError.WrongPassword)
422 -> LoginOutcome.Err(LoginError.InvalidEmail)
429 -> LoginOutcome.Err(LoginError.RateLimited(retryAfter = e.retryAfter()))
else -> LoginOutcome.Err(LoginError.Unknown)
} catch (e: IOException) {
LoginOutcome.Err(LoginError.Network(e))
} catch (e: Throwable) {
LoginOutcome.Err(LoginError.Unknown)
}
Now the caller cannot forget to handle a case — the when is
exhaustive:
when (val outcome = login(email, password)) {
is LoginOutcome.Ok -> router.navigateToHome()
is LoginOutcome.Err -> when (val err = outcome.error) {
LoginError.InvalidEmail -> _state.update { it.copy(emailError = "Invalid email") }
LoginError.WrongPassword -> _state.update { it.copy(passwordError = "Wrong password") }
is LoginError.RateLimited -> _state.update { it.copy(toast = "Try in ${err.retryAfter}") }
is LoginError.Network -> _state.update { it.copy(toast = "No internet") }
LoginError.Unknown -> _state.update { it.copy(toast = "Something went wrong") }
}
}
Adding LoginError.AccountLocked later produces compile errors in
every consumer that doesn't handle it. This is how a codebase stays honest
about failure modes.
Level 3 — Either / Outcome with map and flatMap
For pipelines that chain fallible operations, a generic Either<L, R> or
Outcome<T, E> helps:
sealed interface Outcome<out T, out E> {
data class Ok<T>(val value: T) : Outcome<T, Nothing>
data class Err<E>(val error: E) : Outcome<Nothing, E>
}
inline fun <T, E, R> Outcome<T, E>.map(f: (T) -> R): Outcome<R, E> = when (this) {
is Outcome.Ok -> Outcome.Ok(f(value))
is Outcome.Err -> this
}
inline fun <T, E, R> Outcome<T, E>.flatMap(f: (T) -> Outcome<R, E>): Outcome<R, E> = when (this) {
is Outcome.Ok -> f(value)
is Outcome.Err -> this
}
inline fun <T, E, R> Outcome<T, E>.fold(onOk: (T) -> R, onErr: (E) -> R): R = when (this) {
is Outcome.Ok -> onOk(value)
is Outcome.Err -> onErr(error)
}
inline fun <T, E, E2> Outcome<T, E>.mapError(f: (E) -> E2): Outcome<T, E2> = when (this) {
is Outcome.Ok -> this
is Outcome.Err -> Outcome.Err(f(error))
}
Now compose fallible steps without nested try/catch:
suspend fun checkout(cart: Cart): Outcome<Order, CheckoutError> =
validate(cart)
.flatMap { validCart -> charge(validCart) }
.flatMap { payment -> createOrder(cart, payment) }
.flatMap { order -> saveOrder(order) }
If any step returns Err, the rest short-circuit. Compare to the same
logic with exceptions and nested try/catches.
Level 4 — Arrow's Either and Raise DSL
Arrow is Kotlin's functional library of record. It ships Either<L, R>
and a raise DSL that makes typed-error code look like plain imperative
code.
// Add Arrow
implementation("io.arrow-kt:arrow-core:1.2.4")
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.raise.Raise
suspend fun checkout(cart: Cart): Either<CheckoutError, Order> = either {
val validCart = validate(cart).bind() // short-circuits on Err
val payment = charge(validCart).bind()
val order = createOrder(cart, payment).bind()
saveOrder(order).bind()
order
}
// A suspend function using raise directly
context(Raise<CheckoutError>)
suspend fun validate(cart: Cart): Cart {
ensure(cart.items.isNotEmpty()) { CheckoutError.EmptyCart }
ensure(cart.total > Money.Zero) { CheckoutError.ZeroTotal }
return cart
}
Arrow's either { ... } DSL looks like direct-style code but the .bind()
calls short-circuit on Err. This is the most ergonomic typed-error
experience in Kotlin today.
Resource / UiState variants
The UI doesn't just show success or error — it shows loading too. A three-state wrapper is common:
sealed interface Resource<out T> {
data object Loading : Resource<Nothing>
data class Success<T>(val data: T) : Resource<T>
data class Error(val message: String, val cause: Throwable? = null) : Resource<Nothing>
}
Repositories can stream a Resource:
fun productResource(id: String): Flow<Resource<Product>> = flow {
emit(Resource.Loading)
runCatching { api.getProduct(id).toDomain() }
.onSuccess { emit(Resource.Success(it)) }
.onFailure { emit(Resource.Error(it.message ?: "Unknown", it)) }
}
UI handles all three exhaustively:
@Composable
fun ProductView(resource: Resource<Product>) {
when (resource) {
Resource.Loading -> LoadingIndicator()
is Resource.Success -> ProductDetails(resource.data)
is Resource.Error -> ErrorBanner(resource.message)
}
}
Error hierarchies — organizing by domain
For apps with many features, structure errors per-module:
// :feature:checkout
sealed interface CheckoutError {
data object EmptyCart : CheckoutError
data object ZeroTotal : CheckoutError
data class InvalidAddress(val reason: String) : CheckoutError
sealed interface PaymentFailure : CheckoutError {
data object CardDeclined : PaymentFailure
data object InsufficientFunds : PaymentFailure
data object ExpiredCard : PaymentFailure
data class Fraud(val caseId: String) : PaymentFailure
}
sealed interface NetworkFailure : CheckoutError {
data object NoConnection : NetworkFailure
data object ServerUnreachable : NetworkFailure
data class Timeout(val afterMs: Long) : NetworkFailure
}
}
Nested sealed interfaces group related failures. Callers can pattern-match at whatever specificity they need:
when (val err = outcome.error) {
is CheckoutError.PaymentFailure -> showPaymentError(err) // broad
is CheckoutError.NetworkFailure.Timeout -> retry() // specific
else -> showGenericError()
}
Mapping errors across layers
Errors from one layer (HTTP codes) shouldn't leak into another (UI). Map at boundaries:
Network layer → HttpException(401)
↓ mapped in data layer
Data/Domain layer → AuthError.Unauthorized
↓ mapped in presentation layer
UI layer → UiMessage("Please sign in again")
// Data layer — translates HTTP to domain errors
class UserRepositoryImpl(private val api: UserApi) : UserRepository {
override suspend fun fetch(id: UserId): Outcome<User, UserError> = try {
Outcome.Ok(api.getUser(id.raw).toDomain())
} catch (e: CancellationException) { throw e
} catch (e: HttpException) when (e.code()) {
404 -> Outcome.Err(UserError.NotFound)
401 -> Outcome.Err(UserError.Unauthorized)
else -> Outcome.Err(UserError.Unknown(e))
} catch (e: IOException) {
Outcome.Err(UserError.Network(e))
}
}
// Presentation layer — maps domain errors to user-facing messages
fun UserError.toUiMessage(): String = when (this) {
UserError.NotFound -> "User not found"
UserError.Unauthorized -> "Please sign in again"
is UserError.Network -> "Check your connection"
is UserError.Unknown -> "Something went wrong"
}
Errors in Flow
catch operator
fun userFlow(id: UserId): Flow<User> = api.watchUser(id)
.map { it.toDomain() }
.catch { e ->
Firebase.crashlytics.recordException(e)
emit(User.Anonymous) // fallback, or rethrow
}
catch handles exceptions from upstream operators. It does not catch
exceptions from downstream collectors — that's the separation of
concerns.
retry and retryWhen
fun userFlow(id: UserId): Flow<User> = flow {
emit(api.fetch(id))
}
.retry(retries = 3) { cause -> cause is IOException }
.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
delay((1L shl attempt.toInt()) * 1000L)
true
} else false
}
Exponential backoff with jitter is in the Concurrency Patterns chapter.
Error boundaries in Compose
Compose doesn't have React-style error boundaries by default. Wrap risky composables yourself:
@Composable
fun ErrorBoundary(
fallback: @Composable (Throwable) -> Unit = { DefaultErrorFallback(it) },
content: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
var error by remember { mutableStateOf<Throwable?>(null) }
val composition = currentComposer.recomposeScope
if (error != null) {
fallback(error!!)
return
}
try {
content()
} catch (t: CancellationException) { throw t
} catch (t: Throwable) {
LaunchedEffect(t) {
Firebase.crashlytics.recordException(t)
error = t
}
}
}
@Composable
fun DefaultErrorFallback(t: Throwable) {
Column(Modifier.padding(16.dp)) {
Text("Something went wrong", style = MaterialTheme.typography.titleMedium)
Text(t.message ?: "", style = MaterialTheme.typography.bodySmall)
}
}
// Usage
ErrorBoundary {
ComplexScreen()
}
Use sparingly. Typed-error plumbing at the ViewModel layer handles business errors. Error boundaries are for truly unexpected failures (bugs), giving a graceful fallback instead of a crash.
Never swallow cancellation
The single most common error-handling bug in coroutine code:
// ❌ Swallows CancellationException — breaks structured concurrency
viewModelScope.launch {
try {
doWork()
} catch (e: Exception) {
_state.update { it.copy(error = e.message) }
}
}
// ✅ Re-throw cancellation; handle everything else
viewModelScope.launch {
try {
doWork()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_state.update { it.copy(error = e.message) }
}
}
If the ViewModel is cleared mid-call, cancellation must propagate. If
you swallow it, you're running after the scope is dead.
When to still use exceptions
Not every error belongs in a typed wrapper. Keep exceptions for:
- Programmer errors —
require(),check(),IllegalStateException. These shouldn't recover; they should crash the debug build. - Cancellation — always.
- Truly unrecoverable conditions — OOM, StackOverflow. No point wrapping.
- Boundary I/O that can't produce a typed error — e.g., passing through to a library that expects exceptions.
Use typed errors for business errors the app must handle differently based on the kind (payment declined ≠ network down ≠ invalid input).
Common anti-patterns
What causes bugs
- catch (e: Exception) { e.printStackTrace() }
- catch (Throwable) swallowing CancellationException
- Returning null to mean "error"
- Exposing HttpException to the UI layer
- Single Error message: String — erases context
- Manual rethrow and lose stack trace
Production-grade errors
- Typed sealed error hierarchy per feature
- Always rethrow CancellationException
- Outcome<T, E> or Either<E, T> for explicit branches
- Map HTTP codes to domain errors in :data
- Preserve original Throwable as `cause` for diagnostics
- throw e or exception.addSuppressed() for context
Key takeaways
Practice exercises
- 01
Define a domain error hierarchy
Pick one feature (login, checkout, upload). Write a sealed interface with 5+ named error cases. Use it in the use case return type.
- 02
Replace try/catch with Outcome
Find a repository method that throws. Return Outcome<T, E> instead. Update callers to pattern-match exhaustively.
- 03
Adopt Arrow's Either
Add Arrow to one module. Rewrite a 3-step pipeline as either { step1().bind(); step2().bind(); step3().bind() }.
- 04
Map HTTP to domain
Move every catch (HttpException) out of ViewModels and into the data layer. ViewModel sees only typed domain errors.
- 05
Compose error boundary
Wrap one risky screen in an ErrorBoundary that logs to Crashlytics and shows a retry button.
Continue reading
- Domain Modeling — errors as part of the domain
- Kotlin Idiomatic Patterns — sealed ADTs, value classes
- Concurrency Patterns — retry, circuit breaker, debounce