Error Handling Patterns in Kotlin
Error handling is the practice of anticipating, detecting, and resolving errors that occur during program execution. In Kotlin, error handling goes far beyond basic try-catch blocks — the language provides a rich set of idiomatic patterns that allow developers to write robust, readable, and maintainable code. Understanding these patterns is essential for building production-grade applications that fail gracefully and provide meaningful feedback to users and developers alike.
What Is Error Handling in Kotlin
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Error handling in Kotlin refers to the mechanisms and design patterns used to manage exceptions, recoverable failures, and unexpected conditions. Kotlin treats exceptions as the primary mechanism for propagating errors, similar to Java, but adds functional constructs that transform error handling from a procedural chore into an expressive, composable part of your domain logic.
At its core, error handling answers three fundamental questions:
- What went wrong? — Capturing the nature and context of a failure
- Where did it go wrong? — Preserving stack traces and execution context
- How should we respond? — Recovering, retrying, fallback, or propagating the error upward
Kotlin offers several distinct approaches to error handling, each suited to different scenarios. The choice of pattern depends on whether the error is expected or unexpected, recoverable or unrecoverable, and whether you're working in synchronous or asynchronous contexts.
Why Error Handling Patterns Matter
Poor error handling leads to cryptic crashes, silent data corruption, and frustrating user experiences. A well-designed error handling strategy:
- Prevents cascading failures — One unhandled exception can crash an entire coroutine scope or Android activity, taking down unrelated features with it.
- Improves debuggability — Structured error information with proper context makes root cause analysis dramatically faster.
- Enables graceful degradation — Instead of crashing, your application can show fallback content, retry operations, or guide users toward recovery.
- Separates concerns — Error handling logic stays decoupled from business logic, keeping code clean and testable.
- Supports type safety — The compiler can enforce that callers handle potential failures, eliminating entire categories of runtime bugs.
Pattern 1: Traditional Try-Catch-Finally
The foundational error handling mechanism in Kotlin is the try-catch-finally block. While similar to Java's version, Kotlin's try-catch is more powerful because try is an expression — it returns a value that can be assigned directly to a variable.
Basic Try-Catch-Finally
fun readFileContents(path: String): String {
val file = File(path)
var reader: BufferedReader? = null
try {
reader = file.bufferedReader()
return reader.readText()
} catch (e: FileNotFoundException) {
println("File not found at path: $path")
return ""
} catch (e: IOException) {
println("IO error while reading file: ${e.message}")
throw e // Re-throw if unrecoverable
} finally {
reader?.close() // Always release resources
}
}
Try as an Expression
One of Kotlin's most elegant features is the ability to use try-catch as an expression. This eliminates mutable variables and makes error handling compact and readable.
fun parseInteger(input: String): Int? {
val result = try {
input.toInt()
} catch (e: NumberFormatException) {
println("'$input' is not a valid integer")
null
}
return result
}
// Even more concise usage
val defaultValue = -1
val parsed = try {
userInput.toInt()
} catch (e: NumberFormatException) {
defaultValue
}
println("Parsed value: $parsed")
The try-expression pattern works beautifully when combined with Elvis operator and safe calls for compact fallback chains.
Pattern 2: The runCatching Function
Introduced in Kotlin 1.3, runCatching wraps a block of code in a try-catch and returns a Result<T> object. This is Kotlin's built-in approach to functional error handling, inspired by languages like Rust and Scala.
fun fetchUserFromApi(userId: String): User {
// Simulate an API call that might fail
if (userId.isBlank()) throw IllegalArgumentException("User ID cannot be blank")
return User(id = userId, name = "John Doe")
}
fun getUserName(userId: String): String {
return runCatching {
fetchUserFromApi(userId)
}
.map { user -> user.name }
.getOrDefault("Unknown User")
}
// Usage
println(getUserName("valid-id")) // "John Doe"
println(getUserName("")) // "Unknown User"
Working with Result Values
fun processUserData(rawId: String) {
runCatching {
require(rawId.length >= 3) { "ID must be at least 3 characters" }
fetchUserFromApi(rawId)
}
.onSuccess { user ->
println("Successfully fetched user: ${user.name}")
saveToCache(user)
}
.onFailure { throwable ->
when (throwable) {
is IllegalArgumentException -> println("Validation error: ${throwable.message}")
is IOException -> println("Network error, will retry later")
else -> println("Unexpected error: ${throwable.message}")
}
}
.also { result ->
// Result can be inspected without unwrapping
if (result.isFailure) logErrorToAnalytics(result.exceptionOrNull())
}
}
Recovering from Failures
fun loadCachedOrRemote(key: String): Data {
return runCatching {
fetchFromRemote(key)
}
.recover { throwable ->
println("Remote fetch failed: ${throwable.message}, falling back to cache")
fetchFromCache(key) ?: throw IllegalStateException("No cached data for $key")
}
.getOrThrow()
}
// recoverCatching for nested safety
fun robustFetch(key: String): Data {
return runCatching {
fetchFromRemote(key)
}
.recoverCatching { throwable ->
println("Remote failed, trying cache...")
fetchFromCache(key) ?: throw NoSuchElementException("Cache miss for $key")
}
.getOrDefault(Data.empty())
}
Pattern 3: Custom Sealed Class for Typed Errors
While Result is convenient, it has limitations — the error type is always Throwable, which is too broad for domain-specific errors. A custom sealed class lets you define a precise error hierarchy that callers must handle explicitly.
// Define a typed result for a specific domain
sealed class ValidationResult {
data class Success(val value: T) : ValidationResult()
data class Error(val message: String, val field: String? = null) : ValidationResult()
}
fun validateEmail(input: String): ValidationResult {
return when {
input.isBlank() -> ValidationResult.Error("Email cannot be empty", "email")
!input.contains("@") -> ValidationResult.Error("Invalid email format", "email")
input.contains(" ") -> ValidationResult.Error("Email contains spaces", "email")
else -> ValidationResult.Success(input.lowercase())
}
}
fun validateAge(input: String): ValidationResult {
val age = input.toIntOrNull()
?: return ValidationResult.Error("Age must be a number", "age")
return when {
age < 0 -> ValidationResult.Error("Age cannot be negative", "age")
age > 150 -> ValidationResult.Error("Age seems unrealistic", "age")
else -> ValidationResult.Success(age)
}
}
// Compose multiple validations
fun registerUser(email: String, age: String): ValidationResult {
val emailResult = validateEmail(email)
if (emailResult is ValidationResult.Error) return emailResult
val ageResult = validateAge(age)
if (ageResult is ValidationResult.Error) return ageResult
// Both valid — extract values safely
val validEmail = (emailResult as ValidationResult.Success).value
val validAge = (ageResult as ValidationResult.Success).value
return ValidationResult.Success(User(validEmail, validAge))
}
Exhaustive When Handling
The real power of sealed classes emerges with when expressions — the compiler can verify that all cases are handled.
fun handleValidation(result: ValidationResult) {
when (result) {
is ValidationResult.Success -> {
println("Welcome, ${result.value.email}!")
navigateToDashboard()
}
is ValidationResult.Error -> {
println("Validation failed on field '${result.field}': ${result.message}")
showFieldError(result.field, result.message)
}
// Compiler error if a new subclass is added and not handled here
}
}
Pattern 4: The Either Pattern (Functional Approach)
For more complex error handling inspired by functional programming, an Either type represents one of two possible values — typically Left for errors and Right for success. This pattern allows chaining operations without throwing exceptions.
// Simplified Either implementation
sealed class Either {
data class Left(val value: L) : Either()
data class Right(val value: R) : Either()
fun isLeft(): Boolean = this is Left
fun isRight(): Boolean = this is Right
fun map(transform: (R) -> T): Either = when (this) {
is Left -> this
is Right -> Right(transform(value))
}
fun flatMap(transform: (R) -> Either): Either = when (this) {
is Left -> this
is Right -> transform(value)
}
fun getOrElse(default: (L) -> R): R = when (this) {
is Left -> default(value)
is Right -> value
}
}
// Railway-oriented programming example
data class User(val id: String, val email: String)
data class Order(val id: String, val user: User, val total: Double)
fun findUser(userId: String): Either {
return if (userId.startsWith("u")) {
Either.Right(User(userId, "$userId@example.com"))
} else {
Either.Left("User not found: invalid ID format")
}
}
fun validateUserActive(user: User): Either {
return if (user.email.isNotEmpty()) {
Either.Right(user)
} else {
Either.Left("User account is inactive")
}
}
fun createOrderForUser(user: User): Either {
return Either.Right(Order("ord-${user.id}", user, 99.99))
}
// Chaining operations with flatMap — errors propagate automatically
fun processOrder(userId: String): Either {
return findUser(userId)
.flatMap { user -> validateUserActive(user) }
.flatMap { activeUser -> createOrderForUser(activeUser) }
}
// Usage
fun main() {
val result = processOrder("u12345")
when (result) {
is Either.Left -> println("Order failed: ${result.value}")
is Either.Right -> println("Order created: ${result.value.id}, total: ${result.value.total}")
}
}
Pattern 5: Nullable Types for Absence, Not Errors
Kotlin's nullable types are not strictly error handling, but they prevent the most common runtime exception — NullPointerException. Use nullable returns for expected absence of a value, and reserve exceptions for truly unexpected conditions.
// Nullable for "not found" — this is expected behavior
fun findDocument(id: String): Document? {
return database.query("SELECT * FROM docs WHERE id = ?", id)
.firstOrNull() // Returns null if no row exists
}
// Exception for "cannot connect" — this is unexpected
fun connectToDatabase(): Connection {
return try {
DriverManager.getConnection(Config.dbUrl)
} catch (e: SQLException) {
throw DatabaseConnectionException("Failed to connect: ${e.message}", e)
}
}
// Combining both patterns
fun loadDocumentContent(docId: String): String {
val doc = findDocument(docId)
?: return "" // Expected absence, use null
// If we get here, we have a valid document
return doc.content
}
Elvis Operator and Safe Calls
data class Address(val street: String, val city: String, val country: String?)
data class Customer(val name: String, val address: Address?)
fun getCustomerCountry(customer: Customer?): String {
return customer
?.address // Safe call — returns null if customer is null
?.country // Safe call — returns null if address is null
?: "Unknown" // Elvis operator — provides default
}
// Chain multiple fallbacks
fun getDisplayLocation(customer: Customer?): String {
return customer?.address?.city
?: customer?.address?.street
?: "Location unavailable"
}
Pattern 6: Custom Exception Hierarchy
Creating a domain-specific exception hierarchy adds semantic meaning to failures and enables precise catch clauses.
// Base exception for your domain
abstract class PaymentException(
message: String,
cause: Throwable? = null
) : RuntimeException(message, cause)
class InsufficientFundsException(
required: Double,
available: Double
) : PaymentException(
"Insufficient funds: required $required, available $available"
)
class PaymentTimeoutException(
timeoutMs: Long
) : PaymentException(
"Payment processing timed out after $timeoutMs ms"
)
class FraudDetectionException(
reason: String
) : PaymentException(
"Payment blocked by fraud detection: $reason"
)
// Usage with precise error handling
fun processPayment(amount: Double, account: Account): PaymentResult {
return try {
val gateway = connectToPaymentGateway()
gateway.charge(account, amount)
PaymentResult.Success
} catch (e: InsufficientFundsException) {
println("User needs to top up: ${e.message}")
PaymentResult.Failure("Insufficient funds")
} catch (e: PaymentTimeoutException) {
println("Will retry in background: ${e.message}")
scheduleRetry(account, amount)
PaymentResult.Pending
} catch (e: FraudDetectionException) {
println("Security alert raised: ${e.message}")
notifySecurityTeam(e)
PaymentResult.Blocked
} catch (e: PaymentException) {
// Catch-all for other payment-specific issues
println("Generic payment failure: ${e.message}")
PaymentResult.Failure("Payment failed")
}
}
Pattern 7: Coroutine Exception Handling
Error handling in coroutines requires special attention because exceptions propagate differently depending on the coroutine builder used. Understanding these differences is critical for building reliable asynchronous code.
Launch vs Async Exception Propagation
import kotlinx.coroutines.*
// launch: exceptions are thrown immediately and propagate through the hierarchy
fun testLaunchException() = runBlocking {
val job = launch {
try {
throw RuntimeException("Error in launch")
} catch (e: Exception) {
println("Caught inside launch: ${e.message}")
}
}
job.join()
println("Continuing after launch")
}
// async: exceptions are deferred and thrown when await() is called
fun testAsyncException() = runBlocking {
val deferred = async {
throw RuntimeException("Error in async")
}
try {
deferred.await() // Exception surfaces here
} catch (e: Exception) {
println("Caught at await: ${e.message}")
}
}
Coroutine Exception Handler
fun main() = runBlocking {
val exceptionHandler = CoroutineExceptionHandler { context, throwable ->
println("Global handler caught: ${throwable.message}")
println("Affected coroutine: ${context[CoroutineName]}")
}
val scope = CoroutineScope(SupervisorJob() + exceptionHandler)
// Launch multiple coroutines — one failure won't cancel others
scope.launch(CoroutineName("Task-1")) {
delay(100)
throw RuntimeException("Task-1 failed")
}
scope.launch(CoroutineName("Task-2")) {
delay(200)
println("Task-2 completed successfully")
}
delay(300)
println("Main scope still active despite Task-1 failure")
}
SupervisorJob for Isolation
fun runIndependentTasks() = runBlocking {
// With SupervisorJob, child failures don't cancel siblings or the parent
val supervisorScope = CoroutineScope(SupervisorJob())
supervisorScope.launch {
// This failure won't affect the second launch
throw IllegalStateException("First child crashed")
}
supervisorScope.launch {
delay(100)
println("Second child still runs despite sibling crash")
}
delay(200)
println("Supervisor scope continues")
}
Pattern 8: The Require and Check Functions
Kotlin's standard library provides require and check functions for precondition and state validation. These throw specific exceptions (IllegalArgumentException and IllegalStateException respectively) with minimal boilerplate.
class BankAccount(private var balance: Double) {
fun deposit(amount: Double) {
require(amount > 0) {
"Deposit amount must be positive, got $amount"
}
balance += amount
check(balance > 0) {
"Balance should never be negative after deposit"
}
}
fun withdraw(amount: Double) {
require(amount > 0) { "Withdrawal amount must be positive" }
require(amount <= balance) {
"Insufficient funds: tried to withdraw $amount, balance is $balance"
}
balance -= amount
}
fun getBalance(): Double {
check(balance >= 0) { "Balance invariant violated: $balance" }
return balance
}
}
// requireNotNull and checkNotNull for nullable assertions
fun processUserInput(input: String?) {
val nonNullInput = requireNotNull(input) {
"User input was unexpectedly null"
}
// From here, nonNullInput is smart-cast to non-null String
println("Processing: ${nonNullInput.uppercase()}")
}
Best Practices for Error Handling in Kotlin
1. Distinguish Expected from Unexpected Errors
Use nullable types or Result-like wrappers for expected, recoverable failures. Reserve exceptions for truly unexpected conditions that you cannot reasonably recover from.
// Expected: user not found — return null
fun findUser(id: String): User? = users[id]
// Unexpected: database corruption — throw exception
fun loadCriticalData(): List {
val data = database.query()
if (data.isEmpty() && expectedRowCount > 0) {
throw DataCorruptionException("Expected $expectedRowCount rows, got 0")
}
return data
}
2. Never Catch and Swallow Exceptions Silently
Every caught exception should be logged, re-thrown with context, or handled with a specific recovery action. Silent catch blocks hide bugs and make debugging a nightmare.
// BAD: Swallowing exception
fun badExample() {
try {
riskyOperation()
} catch (e: Exception) {
// Nothing — error vanishes into the void
}
}
// GOOD: Logged and re-thrown with context
fun goodExample() {
try {
riskyOperation()
} catch (e: Exception) {
logger.error("Failed during riskyOperation", e)
throw ServiceException("Service degraded", e)
}
}
3. Preserve the Original Exception Chain
When wrapping exceptions, always pass the original exception as the cause parameter to maintain the full stack trace.
try {
parseJson(input)
} catch (e: JsonParseException) {
throw DataImportException("Failed to parse JSON input", e) // Preserves cause
}
4. Use Sealed Classes for Domain Errors
When errors are part of your domain model (validation failures, business rule violations), sealed classes provide compile-time exhaustiveness checking that exceptions cannot.
5. Leverage runCatching for Functional Pipelines
Use runCatching when you want to chain operations with map, recover, and fold without breaking the functional flow with try-catch blocks.
6. Apply the Principle of Least Surprise in Coroutines
Use SupervisorJob when independent tasks should not cancel each other. Use regular Job when tasks are interdependent and failure should cascade. Always install a CoroutineExceptionHandler at the scope level for unhandled exceptions.
7. Document Error Semantics Clearly
/**
* Retrieves user profile by ID.
*
* @param userId Unique user identifier
* @return User object if found, null if user does not exist
* @throws NetworkException if the remote API is unreachable
* @throws AuthenticationException if the access token has expired
*/
fun getUserProfile(userId: String): User? {
// Implementation
}
8. Test Error Paths Thoroughly
Error handling code is often the least tested part of a codebase. Write unit tests that specifically trigger error conditions.
fun testValidationError() {
val result = validateEmail("invalid")
assertTrue(result is ValidationResult.Error)
assertEquals("email", (result as ValidationResult.Error).field)
}
fun testRecoveryPath() {
val result = runCatching {
throw IOException("Network down")
}.recover { Data.cached() }
assertTrue(result.isSuccess)
}
Choosing the Right Pattern
The table below summarizes when to apply each pattern:
- try-catch expression — Simple operations with immediate fallback values; local error handling within a single function.
- runCatching / Result — Functional pipelines where you want to chain map, recover, and fold without breaking flow.
- Custom sealed class (ValidationResult/Either) — Domain errors that callers must explicitly handle; when you need compile-time exhaustiveness checking.
- Nullable types with Elvis — Expected absence of a value; not-found scenarios that are part of normal operation.
- Custom exception hierarchy — Unexpected failures with different recovery strategies; when stack traces and logging are essential.
- require / check — Contract enforcement at function boundaries; validating arguments and invariants.
- Coroutine exception handling — Asynchronous code; when you need isolation between independent tasks.
Conclusion
Error handling in Kotlin is a rich landscape that rewards deliberate design. By moving beyond ad-hoc try-catch blocks and embracing the language's functional constructs — sealed class results, runCatching, nullable types, and structured coroutine error handling — you create code that not only handles failures gracefully but also communicates error semantics clearly through the type system. The most effective error handling strategy combines multiple patterns: nullable types for expected absence, custom sealed results for domain errors, exceptions for truly unexpected conditions, and supervisor scopes for asynchronous isolation. Invest time in designing your error model early; it pays dividends in reliability, debuggability, and developer confidence throughout the lifetime of your application.