In the previous tutorial, you learned about interfaces and generics. Now let’s learn about error handling. Every program needs to handle errors. Kotlin gives you several tools for this, from traditional try/catch to the modern Result type.

In this tutorial, you will learn:

  • try/catch/finally
  • try as an expression
  • Custom exceptions
  • The Result type (runCatching, getOrElse, map, fold)
  • Sealed classes for errors
  • Error handling patterns and best practices

try/catch/finally

The try/catch block catches exceptions. finally always runs, even after an exception.

try {
    val result = 10 / 0
    println("Result: $result")
} catch (e: ArithmeticException) {
    println("Caught: ${e.message}") // / by zero
}

In Kotlin, all exceptions are unchecked. You do not need to declare which exceptions a function throws.

Multiple catch Blocks

You can catch different exception types:

fun parseAndDivide(text: String, divisor: Int): Int {
    return try {
        val number = text.toInt()
        number / divisor
    } catch (e: NumberFormatException) {
        println("Not a number: $text")
        0
    } catch (e: ArithmeticException) {
        println("Cannot divide by zero")
        0
    }
}

println(parseAndDivide("100", 5))   // 20
println(parseAndDivide("hello", 5)) // 0 (not a number)
println(parseAndDivide("100", 0))   // 0 (divide by zero)

finally

The finally block always runs. Use it to clean up resources:

fun readResource(): String {
    var resource = "open"
    try {
        return "Data loaded"
    } finally {
        resource = "closed"
        println("Resource: $resource") // Always prints
    }
}

try as Expression

In Kotlin, try/catch is an expression. It returns a value. The last expression in the try or catch block is the result.

val number = try {
    "42".toInt()
} catch (e: NumberFormatException) {
    0
}
println(number) // 42

val invalid = try {
    "hello".toInt()
} catch (e: NumberFormatException) {
    -1
}
println(invalid) // -1

This is cleaner than using a var and assigning inside the catch block.

Custom Exceptions

Create your own exception classes by extending Exception:

class ValidationException(message: String) : Exception(message)

class NotFoundException(val id: Int)
    : Exception("Item with id $id not found")

class InsufficientFundsException(
    val balance: Double,
    val amount: Double
) : Exception("Insufficient funds: balance=$balance, amount=$amount")

Use them in your code:

data class BankAccount(val name: String, var balance: Double) {
    fun deposit(amount: Double) {
        require(amount > 0) { "Deposit amount must be positive" }
        balance += amount
    }

    fun withdraw(amount: Double) {
        require(amount > 0) { "Withdrawal amount must be positive" }
        if (amount > balance) {
            throw InsufficientFundsException(balance, amount)
        }
        balance -= amount
    }
}

val account = BankAccount("Alex", 100.0)
account.deposit(50.0)
println(account.balance) // 150.0

try {
    account.withdraw(200.0)
} catch (e: InsufficientFundsException) {
    println("Error: ${e.message}")
    println("Balance: ${e.balance}, Tried: ${e.amount}")
}

Built-in Preconditions

Kotlin has built-in functions for common checks:

FunctionThrowsUse for
require(condition)IllegalArgumentExceptionFunction arguments
check(condition)IllegalStateExceptionObject state
error(message)IllegalStateExceptionUnreachable code
requireNotNull(value)IllegalArgumentExceptionNull checks
fun processName(name: String) {
    require(name.isNotBlank()) { "Name cannot be blank" }
    println("Processing: $name")
}

try {
    processName("")
} catch (e: IllegalArgumentException) {
    println("Caught: ${e.message}") // Name cannot be blank
}

The Result Type

The Result<T> type wraps either a success value or an exception. Use runCatching to create a Result.

Creating Results

val success = runCatching { "42".toInt() }
val failure = runCatching { "hello".toInt() }

println(success.isSuccess) // true
println(failure.isFailure) // true

Getting Values

// getOrNull — returns value or null
println(success.getOrNull()) // 42
println(failure.getOrNull()) // null

// getOrElse — returns value or a default
println(success.getOrElse { 0 }) // 42
println(failure.getOrElse { 0 }) // 0

// getOrDefault
println(failure.getOrDefault(0)) // 0

Transforming Results

// map — transform the success value
val doubled = success.map { it * 2 }
println(doubled.getOrNull()) // 84

// map on failure does nothing
val failDoubled = failure.map { it * 2 }
println(failDoubled.getOrNull()) // null

// recover — convert failure to success
val recovered = failure.recover { 0 }
println(recovered.getOrNull()) // 0

fold — Handle Both Cases

val message = success.fold(
    onSuccess = { "Got: $it" },
    onFailure = { "Error: ${it.message}" }
)
println(message) // Got: 42

val failMessage = failure.fold(
    onSuccess = { "Got: $it" },
    onFailure = { "Error: ${it.message}" }
)
println(failMessage) // Error: For input string: "hello"

Side Effects

success
    .onSuccess { println("Value: $it") }
    .onFailure { println("Error: ${it.message}") }

Result in Functions

Use Result<T> as a return type for functions that can fail:

fun parseNumber(text: String): Result<Int> {
    return runCatching { text.toInt() }
}

fun divide(a: Int, b: Int): Result<Double> {
    return runCatching {
        require(b != 0) { "Cannot divide by zero" }
        a.toDouble() / b.toDouble()
    }
}

Usage:

parseNumber("42")
    .onSuccess { println("Parsed: $it") }
    .onFailure { println("Error: ${it.message}") }

divide(10, 3).fold(
    onSuccess = { println("10 / 3 = ${"%.2f".format(it)}") },
    onFailure = { println("Error: ${it.message}") }
)

You can chain Results:

val result = parseNumber("100")
    .map { it * 2 }
    .map { "Result: $it" }
println(result.getOrElse { "Error" }) // Result: 200

Sealed Classes for Errors

For more control over error types, use sealed classes. This makes errors explicit in the type system.

sealed class ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>()
    data class Failure(val error: ApiError) : ApiResult<Nothing>()
}

sealed class ApiError {
    data class NotFound(val id: Int) : ApiError()
    data class Unauthorized(val reason: String) : ApiError()
    data class NetworkError(val message: String) : ApiError()
    data class ValidationError(val field: String, val message: String) : ApiError()
}

Usage:

data class UserProfile(val id: Int, val name: String, val email: String)

fun fetchProfile(id: Int, isLoggedIn: Boolean): ApiResult<UserProfile> {
    return when {
        !isLoggedIn -> ApiResult.Failure(ApiError.Unauthorized("Must be logged in"))
        id <= 0 -> ApiResult.Failure(ApiError.ValidationError("id", "Must be positive"))
        id == 999 -> ApiResult.Failure(ApiError.NotFound(id))
        else -> ApiResult.Success(UserProfile(id, "Alex", "alex@mail.com"))
    }
}

fun handleResult(result: ApiResult<UserProfile>) {
    when (result) {
        is ApiResult.Success -> {
            println("User: ${result.data.name}")
        }
        is ApiResult.Failure -> when (result.error) {
            is ApiError.NotFound -> println("User ${result.error.id} not found")
            is ApiError.Unauthorized -> println("Auth error: ${result.error.reason}")
            is ApiError.NetworkError -> println("Network: ${result.error.message}")
            is ApiError.ValidationError ->
                println("Validation: ${result.error.field} - ${result.error.message}")
        }
    }
}

The advantage of sealed classes over exceptions is that the compiler checks you handle every case. You cannot forget an error type.

If you add a new ApiError subclass later, the compiler will warn you about every when expression that does not handle it. This is much safer than exceptions, which can be forgotten or caught too broadly.

When to Use Sealed Classes Over Exceptions

Use sealed classes when:

  • The caller should handle every error type explicitly
  • You have multiple, well-defined error categories
  • You want the compiler to check completeness
  • Errors are expected (like validation, not-found, unauthorized)

Use exceptions when:

  • The error is unexpected (like out-of-memory)
  • You want the error to propagate up the call stack
  • You are working with Java libraries that use exceptions

Error Handling Patterns

Pattern 1: Convert to Null

Use Kotlin’s built-in OrNull functions:

val number = "hello".toIntOrNull() // null
val validNumber = "42".toIntOrNull() // 42

Pattern 2: Convert to Default Value

fun parseOrDefault(text: String, default: Int = 0): Int {
    return text.toIntOrNull() ?: default
}

println(parseOrDefault("hello", 99)) // 99

Pattern 3: Chain with Result

fun processData(input: String): String {
    return runCatching { input.toInt() }
        .map { it * 2 }
        .map { "Processed: $it" }
        .getOrElse { "Error: ${it.message}" }
}

println(processData("50"))    // Processed: 100
println(processData("hello")) // Error: For input string: "hello"

Pattern 4: requireNotNull

fun processUser(name: String?) {
    val validName = requireNotNull(name) { "Name is required" }
    println("Processing: $validName")
}

Pattern 5: Validate and Return Early

data class RegistrationForm(
    val name: String,
    val email: String,
    val age: Int
)

fun validate(form: RegistrationForm): List<String> {
    val errors = mutableListOf<String>()
    if (form.name.isBlank()) errors.add("Name is required")
    if (!form.email.contains("@")) errors.add("Invalid email")
    if (form.age < 18) errors.add("Must be 18 or older")
    return errors
}

val form = RegistrationForm("", "invalid", 15)
val errors = validate(form)
if (errors.isNotEmpty()) {
    println("Errors:")
    errors.forEach { println("  - $it") }
} else {
    println("Form is valid")
}
// Errors:
//   - Name is required
//   - Invalid email
//   - Must be 18 or older

Pattern 6: Either-Style with Sealed Class

When you need to return either a success or a specific error:

sealed class Either<out L, out R> {
    data class Left<L>(val value: L) : Either<L, Nothing>()
    data class Right<R>(val value: R) : Either<Nothing, R>()
}

fun parseAge(text: String): Either<String, Int> {
    val number = text.toIntOrNull()
        ?: return Either.Left("Not a number: $text")
    if (number < 0) return Either.Left("Age cannot be negative")
    if (number > 150) return Either.Left("Age too large: $number")
    return Either.Right(number)
}

when (val result = parseAge("25")) {
    is Either.Right -> println("Age: ${result.value}")
    is Either.Left -> println("Error: ${result.value}")
}

Best Practices

  1. Prefer null over exceptions for expected failures (like parsing user input)
  2. Use exceptions for unexpected failures (like programming errors)
  3. Use sealed classes when you have multiple error types that the caller must handle
  4. Use Result for simple success/failure chains
  5. Always provide meaningful error messages in exceptions
  6. Don’t catch Exception or Throwable — be specific about what you catch
  7. Use require/check for preconditions — they make your intent clear

Which Approach to Use?

ApproachBest for
try/catchLow-level code, I/O, third-party libraries
ResultFunctional chains, simple success/failure
Sealed classesComplex error types, API responses
OrNull functionsQuick null-safe conversions
require/checkInput validation, preconditions

Summary

ApproachReturnsBest for
try/catchValue or throwsI/O, third-party libraries
Result<T>Result.success or Result.failureFunctional chains
Sealed classSuccess(data) or Error(info)Complex error types, APIs
toIntOrNull() etc.Value or nullQuick conversions
require/checkThrows if falsePreconditions

Key takeaways:

  • Kotlin exceptions are unchecked — no throws keyword needed
  • try/catch is an expression — it returns a value
  • Result type is great for chaining operations
  • Sealed classes give you the most control over error types
  • require and check make preconditions clear

Source Code

You can find the complete source code for this tutorial on GitHub:

KT-14 Source Code on GitHub

What’s Next?

In this tutorial, you learned about try/catch, custom exceptions, the Result type, sealed class errors, and error handling patterns.

In the next tutorial, you will learn about delegation — by lazy, observable and vetoable delegates, custom delegates, and class delegation.


This is part 14 of the Kotlin Tutorial series. Check out Part 13: Generics if you missed it. Need a quick reference? See the Kotlin Cheat Sheet.