Exceptions #
An exception is a program’s way of signaling that something unexpected has happened — a file not found, a dropped connection, invalid input, or an operation that can’t be completed. Handling exceptions properly is the difference between an app that crashes mysteriously and an app that fails in a controlled, informative way. Kotlin inherits the exception mechanism from the JVM, but with some important differences from Java: all exceptions are unchecked, try is an expression that can return a value, and functions like runCatching are available for a more functional style. This article covers all aspects of exception handling in Kotlin in depth.
The Anatomy of Exceptions in Kotlin #
Every exception is an object inheriting from the Throwable class. The hierarchy on the JVM splits into two: Error (critical JVM problems, can’t be handled) and Exception (conditions that can and should be handled).
flowchart TD
A[Throwable] --> B[Error]
A --> C[Exception]
B --> D[OutOfMemoryError]
B --> E[StackOverflowError]
C --> F[RuntimeException]
C --> G[IOException]
F --> H[NullPointerException]
F --> I[IllegalArgumentException]
F --> J[IllegalStateException]
F --> K[IndexOutOfBoundsException]
F --> L[ArithmeticException]The most common exceptions you’ll encounter in daily Kotlin work are subclasses of RuntimeException — all unchecked and no need to declare them in function signatures.
The try-catch Block
#
try-catch catches exceptions thrown within the try block and executes the matching catch block:
fun divide(a: Int, b: Int): Int {
try {
return a / b
} catch (e: ArithmeticException) {
println("Error: ${e.message}")
return 0
}
}
println(divide(10, 2)) // 5
println(divide(10, 0)) // Error: / by zero, then 0
Multiple Catch Blocks #
You can catch various exception types with multiple catch blocks. Order them from the most specific to the most general:
fun readFile(path: String): String {
try {
val file = java.io.File(path)
return file.readText()
} catch (e: java.io.FileNotFoundException) {
println("File not found: $path")
return ""
} catch (e: java.io.IOException) {
println("Failed to read file: ${e.message}")
return ""
} catch (e: SecurityException) {
println("No permission to read: $path")
return ""
} catch (e: Exception) {
// Catch all other exceptions — be careful with this pattern
println("Unexpected error: ${e.javaClass.simpleName}: ${e.message}")
return ""
}
}
Catching Multiple Exceptions in One Catch #
Kotlin allows catching several exception types at once with |:
fun processText(input: String): Int {
return try {
input.trim().toInt()
} catch (e: NumberFormatException) {
-1
} catch (e: NullPointerException) {
-1
}
}
// A more concise way with multi-catch (since Java 7, supported in Kotlin via JVM)
// Kotlin doesn't have the | syntax for this, but you can use Exception with is checks:
fun processTextTwo(input: String?): Int {
return try {
input!!.trim().toInt()
} catch (e: Exception) {
when (e) {
is NumberFormatException -> { println("Not a number: $input"); -1 }
is NullPointerException -> { println("Input is null"); -2 }
else -> throw e // rethrow unknown ones
}
}
}
try as an Expression
#
In Kotlin, try is an expression that returns a value — the value of the last line of the executed try or catch block. This enables more concise code.
// try as an expression — without explicit return
fun parseNumber(text: String): Int {
return try {
text.toInt()
} catch (e: NumberFormatException) {
0 // default value on failure
}
}
// Directly assign to a variable
val number = try {
"42abc".toInt()
} catch (e: NumberFormatException) {
-1
}
println(number) // -1
// Used directly in an expression
val validAge = try { inputAge.toInt() } catch (e: Exception) { 0 }
println(if (validAge >= 18) "Adult" else "Minor")
The finally Block
#
The finally block always executes — whether an exception occurs or not, whether there’s a return in the middle of try or not. It’s used to guarantee resources are always cleaned up.
fun accessDatabase(query: String): String {
val connection = openConnection()
try {
return connection.execute(query)
} catch (e: Exception) {
println("Query failed: ${e.message}")
return ""
} finally {
connection.close() // ALWAYS executed
println("Connection closed")
}
}
use — A finally Alternative for Resources
#
For objects implementing Closeable or AutoCloseable (like files, connections, streams), Kotlin provides the use function, which automatically closes the resource even if an exception occurs. This is much cleaner than manual try-finally:
// ANTI-PATTERN: manual try-finally for closing resources
fun readFileOld(path: String): String {
val reader = java.io.BufferedReader(java.io.FileReader(path))
try {
return reader.readLine() ?: ""
} finally {
reader.close()
}
}
// CORRECT: use use — cleaner and safer
fun readFile(path: String): String {
return java.io.File(path).bufferedReader().use { reader ->
reader.readText()
}
}
// Multiple resources at once
fun copyFile(from: String, to: String) {
java.io.FileInputStream(from).use { input ->
java.io.FileOutputStream(to).use { output ->
input.copyTo(output)
}
}
}
Throwing Exceptions with throw
#
Use throw to explicitly throw an exception when an invalid condition is detected:
fun validateAge(age: Int) {
if (age < 0) throw IllegalArgumentException("Age must not be negative: $age")
if (age > 150) throw IllegalArgumentException("Unreasonable age: $age")
}
fun getElement(list: List<String>, index: Int): String {
if (list.isEmpty()) throw NoSuchElementException("List is empty")
if (index < 0 || index >= list.size) {
throw IndexOutOfBoundsException("Index $index out of range 0..${list.size - 1}")
}
return list[index]
}
throw as an Expression
#
In Kotlin, throw is an expression of type Nothing — it can be used on the right side of the Elvis operator or in conditional expressions:
// throw in the Elvis operator — concise validation
fun getConfig(key: String): String {
return System.getenv(key)
?: throw IllegalStateException("Environment variable '$key' not found")
}
// throw in an if expression
fun process(input: String?): String {
val text = input ?: throw NullPointerException("Input must not be null")
return text.uppercase()
}
// throw in when
fun handleStatus(code: Int): String = when (code) {
200 -> "OK"
404 -> "Not found"
500 -> "Server error"
else -> throw IllegalArgumentException("Unknown HTTP code: $code")
}
require, check, and error — Idiomatic Validation
#
Kotlin provides built-in functions for validation that are more expressive than manual throw:
// require() — validate function parameters
// Throws IllegalArgumentException if the condition is false
fun makeSquare(side: Double): Double {
require(side > 0) { "Side must be positive, got: $side" }
return side * side
}
// requireNotNull() — validate not null
fun processUser(user: User?) {
val u = requireNotNull(user) { "User must not be null" }
println("Processing: ${u.name}")
}
// check() — validate object state
// Throws IllegalStateException if the condition is false
class Printer {
private var isOn = false
fun turnOn() { isOn = true }
fun print(document: String) {
check(isOn) { "Printer must be turned on before printing" }
println("Printing: $document")
}
}
// error() — throw IllegalStateException with a message
fun getAdmin(list: List<User>): User {
return list.firstOrNull { it.role == "ADMIN" }
?: error("No admin found in the system")
}
The difference between require and check:
| Function | Exception | Used for |
|---|---|---|
require(condition) | IllegalArgumentException | Validating function arguments/parameters |
requireNotNull(value) | IllegalArgumentException | Validating that an argument isn’t null |
check(condition) | IllegalStateException | Validating object state before an operation |
checkNotNull(value) | IllegalStateException | Validating that state isn’t null |
error(message) | IllegalStateException | Conditions that should never happen |
Custom Exceptions #
Create custom exceptions by inheriting from Exception or its subclasses. This enables more specific and descriptive error handling:
// Base exception for the application domain
open class AppException(
message: String,
cause: Throwable? = null
) : Exception(message, cause)
// More specific exception hierarchy
class UserNotFoundException(val id: Long) :
AppException("User with ID $id not found")
class EmailAlreadyRegisteredException(val email: String) :
AppException("Email '$email' is already used by another account")
class InsufficientBalanceException(
val availableBalance: Double,
val required: Double
) : AppException(
"Insufficient balance: have Rp${\"%,.0f\".format(availableBalance)}, " +
"need Rp${\"%,.0f\".format(required)}"
)
class MaxAttemptsExceededException(val maxAttempts: Int) :
AppException("Account locked after $maxAttempts failed login attempts")
Using the Custom Exception Hierarchy #
class UserService(private val repo: UserRepository) {
fun login(email: String, password: String): Token {
val user = repo.findByEmail(email)
?: throw UserNotFoundException(0) // ID unknown when the email doesn't exist
if (user.loginAttempts >= 5) {
throw MaxAttemptsExceededException(5)
}
if (!user.verifyPassword(password)) {
repo.incrementLoginAttempts(user.id)
throw AppException("Email or password is incorrect")
}
return createToken(user)
}
}
// Structured handling at the top layer
fun handleLogin(email: String, password: String) {
try {
val token = service.login(email, password)
println("Login successful, token: ${token.value}")
} catch (e: MaxAttemptsExceededException) {
println("Account locked: ${e.message}")
println("Contact support to unlock")
} catch (e: UserNotFoundException) {
println("Account not found") // generic message for security
} catch (e: AppException) {
println("Login failed: ${e.message}")
} catch (e: Exception) {
println("A system error occurred, try again later")
// log e.stackTrace to the monitoring system
}
}
runCatching — The Functional Approach
#
runCatching is a functional alternative to try-catch. It returns a Result<T> object representing success or failure:
// Basic usage
val result = runCatching { "42".toInt() }
println(result.isSuccess) // true
println(result.getOrNull()) // 42
val failed = runCatching { "not a number".toInt() }
println(failed.isFailure) // true
println(failed.getOrNull()) // null
println(failed.exceptionOrNull()?.message) // For input string: "not a number"
// With a default value
val number = runCatching { "abc".toInt() }.getOrDefault(0)
println(number) // 0
// With error transformation
val message = runCatching { "abc".toInt() }
.getOrElse { e -> -1 }
println(message) // -1
// Chaining operations
val finalResult = runCatching { readFile("/data/config.json") }
.map { fileContent -> parseJson(fileContent) }
.recover { e ->
println("Failed to read file: ${e.message}, using default config")
DefaultConfig()
}
.getOrThrow()
runCatching vs try-catch
#
USE try-catch if:
✓ You need to handle various exception types differently
✓ You need a finally block for cleanup
✓ Imperative code the team is already familiar with
✓ You need to rethrow the exception after logging
USE runCatching if:
✓ The operation may fail and its result is processed further
✓ You want declarative result transformation with map/recover
✓ Functional chained code (pipelines)
✓ You want an elegant default value without verbose try-catch
Exceptions in Kotlin vs Java #
The most important differences between Kotlin and Java regarding exceptions:
| Aspect | Java | Kotlin |
|---|---|---|
| Checked exceptions | Yes — must be caught or declared with throws | No — all are unchecked |
try as an expression | No | Yes — returns a value |
throw as an expression | No | Yes — of type Nothing |
throws declaration | Required for checked exceptions | Optional @Throws for Java interop |
| Resource cleanup | try-finally or try-with-resources | use {} (cleaner) |
Because Kotlin has no checked exceptions, you’re never forced to write try-catch just to make code compile. This reduces boilerplate, but it also means you must be more disciplined in documenting the exceptions your functions may throw.
If a Kotlin function needs to be called from Java code, use the
@Throwsannotation so Java knows what exceptions can be thrown:@Throws(IOException::class, IllegalArgumentException::class) fun readConfig(path: String): String { // implementation }
When Not to Use Exceptions #
Exceptions have a cost — both in performance (building stack traces is expensive) and code readability. Don’t use exceptions for normal control flow.
// ANTI-PATTERN: exceptions for normal control flow
fun findUser(id: Long): User {
return repo.findById(id) ?: throw UserNotFoundException(id)
}
// The caller must try-catch just to check "exists or not"
try {
val user = findUser(42L)
display(user)
} catch (e: UserNotFoundException) {
displayMessage("User not found")
}
// CORRECT: return null for "not found", exceptions for real errors
fun findUser(id: Long): User? = repo.findById(id)
// The caller just uses Elvis or let
val user = findUser(42L)
if (user != null) {
display(user)
} else {
displayMessage("User not found")
}
// Or with runCatching for operations that can legitimately fail
val dbConnection = runCatching { openConnection() }
.getOrElse { e ->
log.error("Failed to connect to DB", e)
return@function // or return a default value
}
A guide for when to use exceptions vs nullable return values:
USE exceptions if:
✓ The condition is truly unexpected and can't be recovered normally
✓ Errors that need handling in a layer far above (cross-cutting concerns)
✓ Object construction fails due to invalid parameters
✓ Contract violations that should never happen
RETURN null or a sealed class if:
✓ "Not found" is a valid, predictable result
✓ Optional operations that may or may not succeed
✓ User input validation (better to return an error message)
✓ Predicted control flow
Summary #
tryis an expression — in Kotlin,tryreturns the value of the executed block. Use this to reduce temporary variables and write more declarative code.- All exceptions are unchecked — there are no checked exceptions in Kotlin. You’re never forced by the compiler to catch exceptions, but that means you must be more proactive in documenting functions that can throw.
use {}for resource cleanup — replace manualtry-finallywithuse {}for allCloseableobjects. Cleaner and impossible to forget closing resources.requireandcheckfor idiomatic validation — userequire()for parameter validation (throwsIllegalArgumentException) andcheck()for object state validation (throwsIllegalStateException).- Custom exception hierarchies — create exception hierarchies that reflect your application domain. This enables specific handling and informative error messages.
- Catch the most specific first — order
catchblocks from the most specific type to the most general.Exceptionas a catch-all must always come last.runCatchingfor a functional style — userunCatchingwhen you want to process the result of a possibly-failing operation declaratively withmap,recover, andgetOrElse.- Don’t use exceptions for control flow — return
nullor a sealed class for predictable conditions (“not found”). Reserve exceptions for truly unexpected conditions that can’t be recovered normally.