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:
| Function | Throws | Use for |
|---|---|---|
require(condition) | IllegalArgumentException | Function arguments |
check(condition) | IllegalStateException | Object state |
error(message) | IllegalStateException | Unreachable code |
requireNotNull(value) | IllegalArgumentException | Null 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
- Prefer null over exceptions for expected failures (like parsing user input)
- Use exceptions for unexpected failures (like programming errors)
- Use sealed classes when you have multiple error types that the caller must handle
- Use Result for simple success/failure chains
- Always provide meaningful error messages in exceptions
- Don’t catch Exception or Throwable — be specific about what you catch
- Use require/check for preconditions — they make your intent clear
Which Approach to Use?
| Approach | Best for |
|---|---|
try/catch | Low-level code, I/O, third-party libraries |
Result | Functional chains, simple success/failure |
| Sealed classes | Complex error types, API responses |
OrNull functions | Quick null-safe conversions |
require/check | Input validation, preconditions |
Summary
| Approach | Returns | Best for |
|---|---|---|
try/catch | Value or throws | I/O, third-party libraries |
Result<T> | Result.success or Result.failure | Functional chains |
| Sealed class | Success(data) or Error(info) | Complex error types, APIs |
toIntOrNull() etc. | Value or null | Quick conversions |
require/check | Throws if false | Preconditions |
Key takeaways:
- Kotlin exceptions are unchecked — no
throwskeyword needed try/catchis an expression — it returns a valueResulttype is great for chaining operations- Sealed classes give you the most control over error types
requireandcheckmake preconditions clear
Source Code
You can find the complete source code for this tutorial 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.