Most of us have transitioned from Java to Kotlin. As we begin developing with Kotlin, our natural approach is to do things the way we did in Java.
We start experimenting: first by trying to avoid nullable types, then playing with data classes and learning about extension functions. At some point, we become eager to explore new approaches to implement everything else.
This article is a part of my journey learning Kotlin. I want to discover what’s beyond the try/catch block for us and delve into different techniques for handling errors in our Kotlin applications.
Let’s get started! 🙂
The Java Way
Let’s implement a simple application together.
We will start by implementing a function that will be responsible for reading the content of a file:
fun readContentFromFile(filename: String): String {
return try {
File(filename).readText()
} catch (e: IOException) {
println("Error reading the file: ${e.message}")
throw e
}
}
Our function tries to read the text from a file, and if anything goes wrong, we print the error message and then raise the exception.
Then, let’s implement another function to transform the content of that file and return two numbers:
fun transformContent(content: String): CalculationInput {
val numbers = content.split(",").mapNotNull { it.toIntOrNull() }
if (numbers.size != 2)
throw Exception("Invalid input format")
return CalculationInput(numbers[0], numbers[1])
}
The transformContent
function starts by splitting the text using the comma as a delimiter, and after that, it converts our chunks into ints. Resulting in a list of ints.
Then, we check if we have only two numbers in this list, and if that’s not the case, we raise an exception stating that we have an invalid input format.
Otherwise, we return a CalculationInput object that will hold our two numbers for further calculations.
This is what our CalculationInput class looks like:
data class CalculationInput(val a: Int, val b: Int)
With that object in our hands, we can call our divide function that will divide the first number by the second and spit out the quotient of this calculation:
fun divide(a: Int, b: Int): Int {
if (b == 0)
throw ArithmeticException("Division by zero is not allowed")
return a / b
}
In this function, we first check if the divisor equals zero, raising an exception if that’s true. Otherwise, we just return the quotient of our division.
Cool, let’s combine everything in our main function and surround everything with a try/catch block.
fun main() {
try {
val content = readContentFromFile("input.txt")
val numbers = transformContent(content)
val quotient = divide(numbers.a, numbers.b)
println("The quotient of the division is $quotient")
} catch (e: IOException) {
println("Error reading the file: ${e.message}")
} catch (e: Exception) {
println("Error: ${e.message}")
}
}
And, just like you, I also looked at it with suspicious eyes. Is it indeed the Kotlin way?
Well, let’s look at alternatives.
Discovering the Kotlin Way
runCatching
The first approach we’re gonna be looking at is the runCatching
context.
Let’s refactor our main function to see what it will look like:
runCatching {
val content = readContentFromFile("input.txt")
val numbers = transformContent(content)
val quotient = Calculator().divide(numbers.a, numbers.b)
println("The quotient of the division is $quotient")
}.onFailure {
println("Error: ${it.message}")
}
runCatching
allows us to write more compact and readable code by encapsulating exception handling in a single function call. Improving the conciseness of our code.
Besides that, it promotes a functional programming style, making it easier to chain operations and handle results in a more idiomatic Kotlin way.
Moreover, the runCatching
context returns an explicit result type that represents either the success or failure of an operation, making it clear how errors should be handled in the calling code.
To showcase this explicit result type, we can refactor our code to look like the following:
fun main() {
val result = runCatching {
val content = readContentFromFile("input.txt")
val numbers = transformContent(content)
val quotient = Calculator().divide(numbers.a, numbers.b)
println("The quotient of the division is $quotient")
}
result.onFailure {
println("Error: ${it.message}")
}
}
The Result
type is more powerful than that, though. And that’s what we’re going to see next.
Result
To showcase the Result
type, we’re gonna refactor our application even deeper.
Let’s start by changing the return type of our functions to return a Result type instead.
For example, our readContentFromFile function will now return a Result
of type String
:
fun readContentFromFile(filename: String): Result<String> {
return try {
Result.success(
File(filename).readText()
)
} catch (e: IOException) {
Result.failure(e)
}
}
Now, our function will return our content wrapped within a Result
object or our exception wrapped within a Result.Failure
object.
Let’s do the same to our other functions:
fun transformContent(content: String): Result<CalculationInput> {
val numbers = content.split(",").mapNotNull { it.toIntOrNull() }
if (numbers.size != 2) {
return Result.failure(Exception("Invalid input format"))
}
return Result.success(CalculationInput(numbers[0], numbers[1]))
}
Make sure you’re also not throwing exceptions within the functions anymore, but instead, you’re returning the exception inside Result.failure()
.
fun divide(a: Int, b: Int): Result<Int> {
if (b == 0)
return Result.failure(Exception("Division by zero"))
return Result.success(a / b)
}
So far, so good.
Now, this is where it gets fun. Result
is a flexible type and allows itself to be handled in different ways. Let’s refactor our main
function and explore these different ways.
Fold: The first one is fold
, a function that requires us to handle both success and failure cases.
fun main() {
val content = readContentFromFile("input.txt")
content.fold(
onSuccess = {
// Do something with the content of the file
},
onFailure = {
println("Error reading the file: ${it.message}")
}
)
}
In our case, with every result, we would have to call fold again, ending up in a nested structure:
fun main() {
readContentFromFile("input.txt").fold(
onSuccess = {content ->
transformContent(content).fold(
onSuccess = {numbers ->
divide(numbers.a, numbers.b).fold(
onSuccess = {quotient ->
println("The quotient of the division is $quotient")
},
onFailure = {
println("Error: ${it.message}")
}
)
},
onFailure = {
println("Error: ${it.message}")
}
)
},
onFailure = {
println("Error reading the file: ${it.message}")
}
)
}
Yeah… That doesn’t look great. Let’s try to map them instead:
Map:
fun main() {
readContentFromFile("input.txt").map { content ->
transformContent(content).map { numbers ->
divide(numbers.a, numbers.b)
}
} }.fold(
onSuccess = { content ->
content.fold(
onSuccess = { numbers ->
numbers.fold(
onSuccess = { quotient ->
println("The quotient of the division is $quotient")
},
onFailure = {
println("Error: ${it.message}")
}
)
},
onFailure = {
println("Error: ${it.message}")
}
)
},
onFailure = {
println("Error: ${it.message}")
}
)
}
That looks a bit better, doesn’t it? Unfortunatelly, there’s no flatMap
function, so we end up with a Result<Result>
here. Requiring us to nest our code multiple times again:
fun main() {
readContentFromFile("input.txt").map { content ->
transformContent(content).map { numbers ->
divide(numbers.a, numbers.b)
}
} }.fold(
onSuccess = { content ->
content.fold(
onSuccess = { numbers ->
numbers.fold(
onSuccess = { quotient ->
println("The quotient of the division is $quotient")
},
onFailure = {
println("Error: ${it.message}")
}
)
},
onFailure = {
println("Error: ${it.message}")
}
)
},
onFailure = {
println("Error: ${it.message}")
}
)
}
Using the result class can make error handling more explicit, easier to read and maintain, and less prone to hidden errors compared to traditional try-catch blocks. However, it can be seen as a bit controversial because since it requires us to handle both success and failure paths, one can say that it’s a reintroduction of checked exceptions back to Kotlin.
So what’s left for us?
The Arrow Way
Arrow is a functional programming library for Kotlin that provides a powerful set of abstractions for working with functional data types.
A few of these constructs are functions that extend the Result class’ capabilities and allow developers to decide whether they want to handle failure paths explicitly. This makes error handling more straightforward and less convoluted in certain situations.
The two constructs we’re gonna be exploring today are the flatMap function and the result
context.
Let’s add Arrow to our dependencies:
dependencies {
implementation("io.arrow-kt:arrow-core:1.2.0-RC")
}
And let’s refactor our main
function once more:
flatMap:
As we discussed before, the Result
type natively provides the map
function. However, when mapping multiple Result objects, we end up with results of results (Result<Result>
).
Arrow enhances the capabilities of the Result
type by providing the flatMap
function, allowing us to end up with only one result in the end:
fun main() {
val result = readContentFromFile("input.txt").flatMap { content ->
transformContent(content).flatMap { numbers ->
divide(numbers.a, numbers.b)
}
}
result.fold(
onSuccess = {quotient ->
println("The quotient of the division is $quotient")
},
onFailure = {
println("Error: ${it.message}")
}
)
}
Result context:
The result
function is a wrapper that executes its block of code within a Result
context, catching any exceptions and wrapping them in a Failure
object.
The bind()
method is used to unwrap the Result
. If the Result
is a Success
, it unwraps the value; if it’s a Failure
, it halts execution and propagates the error.
fun main() {
result {
val content = readContentFromFile("input.txt").bind()
val numbers = transformContent(content).bind()
val quotient = divide(numbers.a, numbers.b).bind()
println("The quotient of the division is $quotient")
}.onFailure {
println("Error: ${it.message}")
}
}
These approaches make our code much cleaner and easier to reason about compared to traditional try/catch blocks. But…
There’s a catch
Should we be catching exceptions in our business code at all? As we saw earlier, Kotlin provides the Result class and runCatching function for more idiomatic error handling. However, it is essential to consider when and where to use these mechanisms.
For example, runCatching catches all sorts of Throwable, including JVM errors like NoClassDefFoundError, ThreadDeath, OutOfMemoryError, or StackOverflowError. Typically, applications should not attempt to recover from these serious problems, as there is often little that can be done to address them. Catch-all mechanisms like runCatching are not recommended for business code, as they can make error handling unclear and convoluted.
Furthermore, it is essential to differentiate between expected errors and unexpected logic errors in business code. While expected errors can be handled and recovered from, unexpected logic errors often indicate programming mistakes that require fixing the code’s logic. Handling both types of errors in the same way, can lead to confusion and make the code difficult to maintain.
getOrThrow()
The Result class also provides a getOrThrow()
function. This function will return the expected value or throw the exception. Let’s see how it works:
fun main() {
val content = readContentFromFile("input.txt").getOrThrow()
val numbers = transformContent(content).getOrThrow()
val quotient = divide(numbers.a, numbers.b).getOrThrow()
println("The quotient of the division is $quotient")
}
For most of our business code, this is the approach we should follow. An exception means there’s a problem with our code. If there’s a problem with our code, we should fix our code.
But then, you may ask: Why result at all?
The Kotlin Way
In the end, by not returning the Result
type and just allowing exceptions to bubble up in our code, we will achieve the same result:
fun main() {
val content = readContentFromFile("input.txt")
val numbers = transformContent(content)
val quotient = Calculator().divide(input.a, input.b)
println("The quotient of the division is $quotient")
}
If the file does not exist or the input is incorrect, we will end up with exceptions being thrown anyway.
The truth is, for most of our business code, we shouldn’t worry about catching exceptions.
“As a rule of thumb, you should not be catching exceptions in general Kotlin code. That’s a code smell. Exceptions should be handled by some top-level framework code of your application to alert developers of the bugs in the code and to restart your application or its affected operation. That’s the primary purpose of exceptions in Kotlin.“
— Roman Elizarov (Project Lead for the Kotlin Programming Language)
As we discussed in the previous section, it often doesn’t make sense to catch exceptions in business code because they appear when the developer makes a mistake and the logic of the code is broken.
Instead of catching exceptions that are not recoverable, we should instead fix our code’s logic.
Conclusion
In Kotlin, traditional try-catch blocks can make your code harder to read and maintain. Instead, the language encourages using more idiomatic error-handling techniques, such as the Result class and the runCatching function, to improve code readability and maintainability.
However, it’s crucial to differentiate between expected errors and unexpected logic errors in your code and decide when and where to use error-handling mechanisms. Libraries like Arrow can provide additional tools to make error handling even more straightforward and less convoluted.
By following these best practices and using the appropriate tools, you can write more readable, maintainable, and effective Kotlin code.
And most of the time, the best choice is to keep your code simple and not overcomplicate it 😁
Examples on GitHub:
GitHub – Kotlin Error Handling Examples
Source
- Kotlin and Exception by Roman Elizarovs
- When and how to use Result in Kotlin?
- Kotlin & Functional Programming: Pick the best, skip the rest by Urs Peter