Exceptions, Result & Defensive Kotlin
Production-grade error handling patterns for robust Android apps
Open interactive version (quiz + challenge)Real-world analogy
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
- try/catch/finally basics — try { } catch (e: IOException) { } finally { } — finally always runs (cleanup, closing resources). Kotlin try is an expression: val result = try { parse(json) } catch (e: Exception) { null }. Catch specific exceptions before general ones.
- Kotlin Result type — Result is a built-in Kotlin type wrapping either a success value (T) or a failure (Throwable). Created by runCatching { } or Result.success(value) / Result.failure(exception). Avoids exceptions for expected error paths.
- runCatching — val result = runCatching { api.fetchUser(id) } wraps any exception into a Result.failure. Idiomatic way to convert throwing code into Result-returning code. Catches all Throwable subclasses including CancellationException — beware in coroutines.
- Result.fold — result.fold(onSuccess = { data -> }, onFailure = { error -> }) processes both cases inline. Returns a value from both branches. More concise than if/else on result.isSuccess.
- getOrElse and getOrNull — result.getOrElse { throwable -> defaultValue } — returns default on failure. result.getOrNull() — returns null on failure (no default). result.getOrThrow() — unwraps or rethrows the original exception.
- Custom exception hierarchy — sealed class AppException : Exception() with subclasses (NetworkException, DatabaseException, ValidationException) creates a domain-specific exception hierarchy. Senior interviewers look for this vs throwing generic RuntimeException everywhere.
- Checked vs unchecked in Kotlin — Kotlin has NO checked exceptions (unlike Java). All exceptions are unchecked. This simplifies APIs but means callers won't be warned by the compiler. Document throwing behavior with @Throws for Java interop. Use Result/sealed class for expected failure paths.
- Defensive programming with require/check/error — require(id > 0) { 'ID must be positive' } throws IllegalArgumentException. check(isInitialized) { 'Not initialized' } throws IllegalStateException. error('Should not reach here') throws IllegalStateException. Use for invariant enforcement.
- Nullable return vs exception — when to choose — Return null for 'not found' (expected empty result). Throw exception for 'unexpected system failure'. Return Result for operations that can fail in multiple known ways. Never throw exception for user input errors — return validation result.
- Exception swallowing anti-pattern — catch (e: Exception) { } — swallowing exceptions silently is one of the most dangerous bugs in production apps. Always at minimum log the exception. In Android, report to Crashlytics: catch (e: Exception) { Crashlytics.recordException(e); handleError(e) }
- @Throws annotation for Java interop — @Throws(IOException::class) fun readFile(): String tells Java callers this method throws IOException (Java checked exception behavior). Without it, Java code won't be warned and may skip catching it.
- try-with-resources equivalent — Kotlin uses use { } extension: FileInputStream(file).use { stream -> stream.read() }. use automatically calls close() even if an exception occurs. Works on any Closeable/AutoCloseable.
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. sealed class AppException — all app failures have a common type; when(error) can exhaustively handle them
- 2. class NetworkException(val httpCode: Int) — carries the HTTP code; caller can check 401 vs 500 vs 404 differently
- 3. suspend fun fetchClaim(): Result = runCatching { } — wraps entire suspend block; all exceptions become Result.failure
- 4. if (!response.isSuccessful) throw AppException.NetworkException(...) — converts HTTP error to domain exception inside runCatching
- 5. repository.fetchClaim(id).fold(onSuccess = {}, onFailure = {}) — processes both branches; fold returns a value from both
- 6. when(error) { is AppException.NetworkException -> } — smart cast inside when; error.httpCode accessible without explicit cast
- 7. require(claim.amount > 0) { 'message' } — throws IllegalArgumentException with message if false; use for parameter validation
- 8. check(userSession.isAuthenticated) — throws IllegalStateException; use for object state preconditions
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Exceptions in Kotlin (kotlinlang.org)
- Kotlin Result Class (kotlinlang.org)
- Error Handling in Android Architecture (developer.android.com)
- runCatching and CancellationException (kotlinlang.org)