Polyglot
Languages Kotlin error handling
Kotlin § error-handling

Error handling

Kotlin’s error-handling model is exception-basedThrowable and its subclasses (Exception, Error) admit substantial Java-style exception handling. The principal mechanisms: try/catch/finally (admits expression form — produces a value), throw, custom exception types (subclassing Throwable or its descendants), the Result<T> type for value-style error handling, and runCatching / getOrElse / getOrThrow for fluent Result composition. Unlike Java, Kotlin has no checked exceptions — admit try/catch only when desired. The defer-equivalent via try/finally and the use extension for Closeable-resource cleanup admit substantial resource management. The combination — exception-based throwing, the substantial Result<T> value type, the absence of checked exceptions, the expression-form try, the use extension — is the substance of Kotlin’s error-handling surface.

throw

Kotlin’s throw is an expression — admits returning Nothing:

fun divide(a: Int, b: Int): Int {
    if (b == 0) throw ArithmeticException("division by zero")
    return a / b
}

The throw expression is admitted everywhere an expression admits — admit substantial conciseness with Elvis:

val n = parseInt(input) ?: throw IllegalArgumentException("not a number")

Exception types

The principal hierarchy:

Throwable
├── Error (unrecoverable; rare in user code)
│   └── OutOfMemoryError, StackOverflowError, ...
└── Exception
    ├── RuntimeException
    │   ├── IllegalArgumentException
    │   ├── IllegalStateException
    │   ├── ArithmeticException
    │   ├── IndexOutOfBoundsException
    │   ├── ClassCastException
    │   ├── ConcurrentModificationException
    │   └── ... (and others)
    ├── IOException
    │   ├── FileNotFoundException
    │   └── ...
    └── (and others)

Custom exceptions:

class ValidationException(
    val field: String,
    message: String,
    cause: Throwable? = null
) : Exception("Validation error in $field: $message", cause)

class NetworkException(
    val statusCode: Int,
    message: String
) : Exception(message)

throw ValidationException("email", "must contain @")

try/catch/finally

The principal handler:

try {
    riskyOperation()
} catch (e: NetworkException) {
    log("network: ${e.message}")
} catch (e: ValidationException) {
    log("validation: ${e.field}: ${e.message}")
} catch (e: Exception) {
    log("unexpected: ${e.message}")
} finally {
    cleanup()
}

The catch blocks are tried in order; the first matching one runs. The finally runs whether or not an exception occurred — admit substantial cleanup.

try as expression

try admits returning a value:

val n: Int = try {
    parseInt(input)
} catch (e: NumberFormatException) {
    0
}

val data: Data? = try {
    fetchData()
} catch (e: Exception) {
    log(e)
    null
}

// With finally:
val result: String = try {
    compute()
} catch (e: Exception) {
    "error: ${e.message}"
} finally {
    cleanupResources()
}

The try block’s last expression is the value (or the matching catch block’s last expression).

No checked exceptions

Unlike Java, Kotlin does not require declaring exceptions:

fun fetch(url: String): String {                   // no throws clause
    val connection = URL(url).openConnection()     // may throw IOException
    return connection.getInputStream().bufferedReader().use { it.readText() }
}

The mechanism admits substantial flexibility but requires substantial discipline — exceptions should be documented in KDoc:

/**
 * Fetches the URL contents.
 *
 * @throws IOException if the network operation fails
 * @throws MalformedURLException if the URL is invalid
 */
fun fetch(url: String): String { /* ... */ }

For Java interop, the @Throws annotation admits Kotlin code declaring exceptions visible to Java callers:

@Throws(IOException::class)
fun fetch(url: String): String { /* ... */ }

Result<T>

The kotlin.Result<T> admits value-style error handling — wraps a successful value or a failure:

fun parseInt(s: String): Result<Int> {
    return try {
        Result.success(s.toInt())
    } catch (e: NumberFormatException) {
        Result.failure(e)
    }
}

val result = parseInt("42")

// Inspection:
result.isSuccess                                    // true
result.isFailure                                    // false
result.getOrNull()                                  // 42 or null
result.exceptionOrNull()                            // null or Throwable

// Get-or-default:
val n = result.getOrDefault(0)
val n = result.getOrElse { 0 }
val n = result.getOrThrow()                         // unwrap or throw

// Transform:
val doubled: Result<Int> = result.map { it * 2 }
val str: Result<String> = result.map { it.toString() }
val recovered: Result<Int> = result.recover { 0 }   // only on failure

// Fold:
val message = result.fold(
    onSuccess = { "got: $it" },
    onFailure = { "error: ${it.message}" }
)

runCatching

The runCatching admits catching exceptions into a Result:

val result: Result<Int> = runCatching {
    s.toInt()
}

val result: Result<Data> = runCatching {
    fetchData()
}

// Chaining:
val n: Int = runCatching { input.toInt() }
    .recover { 0 }
    .getOrThrow()

// With operations:
runCatching { fetchData() }
    .onSuccess { process(it) }
    .onFailure { log(it) }
    .getOrNull()

The mechanism admits substantial fluent error handling without explicit try/catch.

use for resources

The use extension on Closeable admits Java-style try-with-resources:

File("data.txt").bufferedReader().use { reader ->
    val text = reader.readText()
    process(text)
}                                                   // reader closed automatically

The use calls close() on the receiver when the block returns (or throws). Treated in I/O.

For multiple resources:

File("input.txt").bufferedReader().use { input ->
    File("output.txt").bufferedWriter().use { output ->
        input.lineSequence().forEach { line ->
            output.write(line.uppercase())
            output.newLine()
        }
    }                                               // output closed
}                                                   // input closed

require, check, error

For pre-condition and invariant checks:

fun divide(a: Int, b: Int): Int {
    require(b != 0) { "denominator must not be zero" }
    return a / b
}

class Account(initialBalance: Int) {
    var balance: Int = initialBalance
        private set

    fun deposit(amount: Int) {
        require(amount > 0) { "amount must be positive" }
        balance += amount
    }

    fun withdraw(amount: Int) {
        require(amount > 0) { "amount must be positive" }
        check(balance >= amount) { "insufficient funds" }
        balance -= amount
    }
}

fun unreachable(): Nothing = error("unreachable code")

The principal differences:

  • require — argument validation; throws IllegalArgumentException.
  • check — invariant validation; throws IllegalStateException.
  • error — unconditional throw; throws IllegalStateException. Returns Nothing.

The lazy-message lambda admits substantial computation only on failure.

requireNotNull and checkNotNull

fun process(input: String?) {
    requireNotNull(input) { "input must not be null" }
    // input is String here (smart cast)
    println(input.length)
}

fun verify(state: State?) {
    checkNotNull(state) { "state was not set" }
    println(state.value)                            // smart cast to State
}

Common patterns

Validate-and-throw

fun createUser(name: String, age: Int, email: String): User {
    require(name.isNotBlank()) { "name required" }
    require(age in 0..150) { "invalid age: $age" }
    require(email.contains("@")) { "invalid email" }

    return User(name, age, email)
}

Wrap external errors

fun loadConfig(path: String): Config {
    return try {
        val text = File(path).readText()
        Config.parse(text)
    } catch (e: IOException) {
        throw ConfigException("Failed to read $path", e)
    } catch (e: ParseException) {
        throw ConfigException("Invalid config at $path", e)
    }
}

Result-style API

sealed class FetchError {
    object Network : FetchError()
    object Timeout : FetchError()
    data class Status(val code: Int) : FetchError()
}

suspend fun fetch(url: String): Result<Data> = runCatching {
    val response = httpClient.get(url)
    if (!response.isSuccessful) {
        throw HttpException(response.code)
    }
    response.body
}

Resource cleanup with use

fun copyFile(src: File, dst: File) {
    src.inputStream().use { input ->
        dst.outputStream().use { output ->
            input.copyTo(output)
        }
    }
}

Retry with substantial logic

inline fun <T> retry(
    attempts: Int = 3,
    initialDelay: Long = 100,
    factor: Double = 2.0,
    block: () -> T
): T {
    var lastError: Throwable? = null
    var delay = initialDelay

    repeat(attempts) {
        try {
            return block()
        } catch (e: Throwable) {
            lastError = e
            if (it < attempts - 1) {
                Thread.sleep(delay)
                delay = (delay * factor).toLong()
            }
        }
    }

    throw lastError!!
}

Expression-form try

fun parsePort(s: String): Int = try {
    s.toInt().also { require(it in 1..65535) { "out of range" } }
} catch (e: NumberFormatException) {
    -1
}

val port = parsePort(input)
if (port < 0) error("invalid port")

Type-based catch chains

try {
    operation()
} catch (e: IOException) {
    handleIO(e)
} catch (e: ValidationException) {
    handleValidation(e)
} catch (e: NetworkException) {
    handleNetwork(e)
} catch (e: Exception) {
    handleUnknown(e)
}

Error wrapping with cause

class AppException(
    message: String,
    cause: Throwable? = null
) : Exception(message, cause)

fun loadData(): Data {
    return try {
        Database.query(sql)
    } catch (e: SQLException) {
        throw AppException("Database error", e)     // preserves cause
    }
}

// Inspect cause chain:
try {
    loadData()
} catch (e: AppException) {
    val cause = e.cause                             // SQLException
    log("error: ${e.message}, caused by: ${cause?.message}")
}

Validation with multi-error collection

data class ValidationError(val field: String, val message: String)

class ValidationException(val errors: List<ValidationError>) :
    Exception("Validation failed: ${errors.size} errors")

fun validate(form: Form) {
    val errors = mutableListOf<ValidationError>()
    if (form.name.isBlank()) errors.add(ValidationError("name", "required"))
    if (form.age < 0) errors.add(ValidationError("age", "must be non-negative"))
    if (form.email.isNotBlank() && !form.email.contains("@")) {
        errors.add(ValidationError("email", "invalid format"))
    }

    if (errors.isNotEmpty()) {
        throw ValidationException(errors)
    }
}

Asynchronous error handling

suspend fun fetchData(): Result<Data> = runCatching {
    httpClient.get<Data>("https://example.com")
}

// Usage:
val result = fetchData()
result.onSuccess { data -> println("got $data") }
      .onFailure { e -> log(e) }

// Or with fold:
val message = fetchData().fold(
    onSuccess = { "Got ${it.size} items" },
    onFailure = { "Error: ${it.message}" }
)

Treated in Coroutines.

try with finally for cleanup

fun processFile(file: File): String {
    val reader = file.bufferedReader()
    try {
        return reader.readText()
    } finally {
        reader.close()                              // always closes
    }
}

// Conventional alternative with use:
fun processFile(file: File): String {
    return file.bufferedReader().use { it.readText() }
}

Default value with runCatching

val port: Int = runCatching { configString.toInt() }.getOrDefault(8080)
val data: Data = runCatching { loadData() }.getOrElse { defaultData }

Result composition

suspend fun fetchAndProcess(): Result<ProcessedData> {
    return fetchData()
        .map { it.processFirstStep() }
        .map { it.processSecondStep() }
        .recoverCatching {
            // recover or transform error
            DefaultData()
        }
}

Chained validation

fun validateAndSubmit(form: Form): Result<Receipt> {
    return runCatching {
        validate(form)
        process(form)
    }.recover {
        when (it) {
            is ValidationException -> Receipt.Invalid(it.errors)
            else -> Receipt.Error(it.message ?: "unknown error")
        }
    }
}

Sealed exception hierarchy

sealed class AppException(message: String, cause: Throwable? = null) :
    Exception(message, cause)

class NetworkException(val statusCode: Int) : AppException("HTTP $statusCode")
class ValidationException(val field: String) : AppException("Invalid: $field")
class TimeoutException : AppException("Timeout")

fun handle(e: AppException) = when (e) {
    is NetworkException -> retryWithBackoff()
    is ValidationException -> reportField(e.field)
    is TimeoutException -> notifyTimeout()
}

Catching multiple exception types (since 1.7+)

The catch (e: NetworkException | TimeoutException) syntax is not admitted directly. Use a sealed exception hierarchy or sequential catches:

try {
    operation()
} catch (e: NetworkException) {
    handle(e)
} catch (e: TimeoutException) {
    handle(e)
} catch (e: Exception) {
    handleOther(e)
}

Or with when:

try {
    operation()
} catch (e: Exception) {
    when (e) {
        is NetworkException, is TimeoutException -> handleRetryable(e)
        is ValidationException -> handleValidation(e)
        else -> throw e
    }
}

A note on coroutine cancellation

Coroutine cancellation throws CancellationExceptionshould not be caught by routine error handlers:

suspend fun work() {
    try {
        doSomething()
    } catch (e: CancellationException) {
        cleanup()
        throw e                                     // rethrow!
    } catch (e: Exception) {
        log(e)
    }
}

Treated in Coroutines.

A note on the conventional discipline

The contemporary Kotlin error-handling advice:

  • Use exceptions for exceptional conditions.
  • Use Result<T> for value-style error handling.
  • Use runCatching for substantial fluent error chains.
  • Use require/check/error for preconditions and invariants.
  • Use use for resource cleanup with Closeable.
  • Use try as expression for value-returning fallback patterns.
  • Use sealed exception hierarchies for substantial typed errors.
  • Document exceptions in KDoc.
  • Use @Throws for Java interop.
  • Don’t catch CancellationException in coroutines.
  • Subclass RuntimeException or Exception (not Error) for application errors.
  • Use cause to preserve underlying errors.

The combination — Java-style exceptions without checked-exception requirement, the Result<T> value-typed alternative, the substantial runCatching for fluent handling, the use for resource cleanup, the require/check/error preconditions — is the substance of Kotlin’s error-handling surface. The discipline produces clear, type-safe, fluent error handling with substantial flexibility for both exception-style and value-style patterns.