Error handling
Kotlin’s error-handling model is exception-based — Throwable 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; throwsIllegalArgumentException.check— invariant validation; throwsIllegalStateException.error— unconditional throw; throwsIllegalStateException. ReturnsNothing.
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 CancellationException — should 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
runCatchingfor substantial fluent error chains. - Use
require/check/errorfor preconditions and invariants. - Use
usefor resource cleanup withCloseable. - Use
tryas expression for value-returning fallback patterns. - Use sealed exception hierarchies for substantial typed errors.
- Document exceptions in KDoc.
- Use
@Throwsfor Java interop. - Don’t catch
CancellationExceptionin coroutines. - Subclass
RuntimeExceptionorException(notError) for application errors. - Use
causeto 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.