Lesson 6 of 83 intermediate

Exceptions, Result & Defensive Kotlin

Production-grade error handling patterns for robust Android apps

Open interactive version (quiz + challenge)

Real-world analogy

Exception handling is like a safety net under a trapeze artist — you hope it never fires, but it must be there. Kotlin's Result type is like a delivery service that either gives you the package or a note explaining why it couldn't deliver — no surprise crashes.

What is it?

Kotlin's error handling toolkit includes traditional try/catch for unexpected failures, the Result type for expected failure paths, and defensive functions (require/check) for invariant enforcement. The key senior-level skill is knowing WHEN to use each approach — not throwing exceptions for normal control flow, not returning nullable when the failure reason matters.

Real-world relevance

In a fintech claims app, parsing a claim document uses runCatching{} because network/parse failures are expected. A custom AppException sealed hierarchy (NetworkException, ParseException, ValidationException) provides structured error handling. The ViewModel uses Result.fold to update UiState.Error or UiState.Success. require() guards business rule invariants before claim submission.

Key points

Code example

// Custom exception hierarchy
sealed class AppException(message: String, cause: Throwable? = null) : Exception(message, cause) {
    class NetworkException(message: String, val httpCode: Int, cause: Throwable? = null)
        : AppException(message, cause)
    class DatabaseException(message: String, cause: Throwable? = null)
        : AppException(message, cause)
    class ValidationException(val field: String, message: String)
        : AppException(message)
}

// Repository returning Result — explicit failure contract
suspend fun fetchClaim(id: String): Result<Claim> = runCatching {
    val response = api.getClaim(id)
    if (!response.isSuccessful) {
        throw AppException.NetworkException(
            message = response.message(),
            httpCode = response.code()
        )
    }
    response.body() ?: throw AppException.NetworkException("Empty body", 204)
}

// ViewModel consuming Result
fun loadClaim(id: String) {
    viewModelScope.launch {
        _uiState.value = UiState.Loading
        repository.fetchClaim(id).fold(
            onSuccess = { claim ->
                _uiState.value = UiState.Success(claim)
            },
            onFailure = { error ->
                val message = when (error) {
                    is AppException.NetworkException -> "Network error ${error.httpCode}"
                    is AppException.ValidationException -> "Invalid ${error.field}"
                    else -> "Unexpected error: ${error.message}"
                }
                _uiState.value = UiState.Error(message)
            }
        )
    }
}

// Defensive programming with require/check
fun submitClaim(claim: Claim) {
    require(claim.amount > 0) { "Claim amount must be positive, got ${claim.amount}" }
    require(claim.documents.isNotEmpty()) { "At least one document required" }
    check(userSession.isAuthenticated) { "User must be authenticated to submit" }
    processClaim(claim)
}

// Safe resource handling with use
fun readClaimDocument(path: String): ByteArray =
    FileInputStream(path).use { stream -> stream.readBytes() }

Line-by-line walkthrough

  1. 1. sealed class AppException — all app failures have a common type; when(error) can exhaustively handle them
  2. 2. class NetworkException(val httpCode: Int) — carries the HTTP code; caller can check 401 vs 500 vs 404 differently
  3. 3. suspend fun fetchClaim(): Result = runCatching { } — wraps entire suspend block; all exceptions become Result.failure
  4. 4. if (!response.isSuccessful) throw AppException.NetworkException(...) — converts HTTP error to domain exception inside runCatching
  5. 5. repository.fetchClaim(id).fold(onSuccess = {}, onFailure = {}) — processes both branches; fold returns a value from both
  6. 6. when(error) { is AppException.NetworkException -> } — smart cast inside when; error.httpCode accessible without explicit cast
  7. 7. require(claim.amount > 0) { 'message' } — throws IllegalArgumentException with message if false; use for parameter validation
  8. 8. check(userSession.isAuthenticated) — throws IllegalStateException; use for object state preconditions
  9. 9. FileInputStream(path).use { stream -> } — use calls close() in finally; safe even if stream.readBytes() throws

Spot the bug

suspend fun loadData(): String {
    return try {
        api.fetchData()
    } catch (e: Exception) {
        "default"  // swallowing CancellationException!
    }
}

fun validateAge(age: Int) {
    if (age < 0) {
        throw Exception("Age cannot be negative")  // too generic
    }
    if (age > 150) {
        throw RuntimeException("Age unrealistic")  // wrong exception type
    }
}
Need a hint?
CancellationException must propagate. Prefer specific exception types and require() for validation.
Show answer
Bug 1: catch (e: Exception) catches CancellationException, breaking coroutine cancellation. Fix: catch (e: Exception) { if (e is CancellationException) throw e; return 'default' } or use catch (e: IOException) for specific errors only. Bug 2: throw Exception() and throw RuntimeException() are too generic — lose semantic meaning. Fix: Use require(age >= 0) { 'Age cannot be negative' } and require(age <= 150) { 'Age unrealistic' } which throw IllegalArgumentException — the semantically correct type for argument validation.

Explain like I'm 5

Imagine you order pizza online. If the order works — great, you get pizza (success). If the restaurant is closed, or payment fails, or you gave the wrong address — each of these is a DIFFERENT kind of failure with different fix-it instructions. Kotlin's Result type is the delivery tracking app that tells you exactly WHAT went wrong, not just 'delivery failed'.

Fun fact

Kotlin's runCatching catches CancellationException — this is a known footgun in coroutines. When a coroutine is cancelled, CancellationException is thrown and must propagate for structured concurrency to work. Wrapping a suspend function in runCatching can silently swallow cancellation, causing coroutines to continue running after they should be cancelled.

Hands-on challenge

Design the complete error handling layer for a school management app's grade submission feature. Create: 1) A sealed class GradeException (ValidationException with field and rule, NetworkException with httpCode, ConcurrentEditException with conflictingUserId). 2) A suspend fun submitGrade(grade: Grade): Result that uses runCatching and maps HTTP error codes to specific AppException types. 3) A ViewModel function handleSubmit() that processes the Result using fold and updates a UiState sealed class. Include require() guards for grade validation.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery