Effective and Idiomatic Error Handling in Kotlin (Tutorial)
In many programs written in Kotlin, including those available on GitHub and in some articles, especially on Medium, I repeatedly see that authors handle errors, from my point of view, inefficiently and non-idiomatically. However, the program’s result can be correct. Inefficiency and ‘non-idiomaticity’ manifest in these cases when authors either use additional classes, create unnecessary workarounds, or make the code more complex to read, understand, and maintain than necessary. And our new friend, ChatGPT, at least in my experiments, seemingly influenced by bad examples, suggests very strange solutions.
Without claiming to be the ultimate authority, I will attempt to describe a systematic approach to effective and idiomatic error handling in Kotlin in this article. This publication should be of interest primarily to Kotlin newcomers, but I hope it will also be engaging for professionals.
You can find the complete source code for this article here.
I must confess that the criticisms mentioned above also apply to some of my own older Kotlin programs. And I believe that the reasons behind the problems faced by many programmers who handle errors non-idiomatically in Kotlin are the same as mine:
- A single incorrect parameter name in the documentation, which ‘scares away’ many users, as it did with me (more on that below).
- Excessive brevity in Kotlin documentation, especially the absence of motivation and usage examples.
- Novice Kotlin developers attempting to apply familiar patterns from other programming languages instead of learning and subsequently using Kotlin’s core features.
Just a little bit of theory
Let’s consider, for simplicity, a certain function implemented in some (currently unimportant) programming language that calculates or changes a value. If an error occurs during the execution of this function, the subsequent logic should generally change compared to normal execution. To achieve this, we need to notify the ‘outside world’ about the error.
I am personally aware of the following ways to notify about such errors, which have been popular in various programming languages at different times:
1. Write information about the error to an external (usually global) variable or object.
2. Use a special language construct called an exception, where information about the error is recorded, and which, by interrupting normal execution logic, begins to move up the call stack until it encounters its handler.
3. In each function, have a special output parameter into which information about the error is recorded.
4. Be able to return either the calculated result or information about the error as the return parameter.
Each of the listed approaches has its advantages and disadvantages. Let’s talk about the disadvantages.
In the first and third cases, after calling each function, you need to check and then clear the corresponding variable or object. This makes the code ‘dirty.’
The use of the exception mechanism (the second approach) is the basic error handling mechanism in Java. The internet is filled with complaints from Java programmers on this topic. Without going into details, I will mention two publications on this topic: ‘Checked Exceptions are Evil’ and ‘Why are exceptions considered bad in many programming languages when they make your code cleaner?’
The first two approaches also suffer from side effects. In other words, the state of the calling environment of our hypothetical function differs when normal execution occurs and when an error occurs. This is the cause of subsequent elusive errors that are very difficult to catch.
The third approach does not create such a problem but is very annoying due to the presence of an additional, inexplicable parameter from the perspective of business logic.
As a result, for many practitioners and programming theorists today, the fourth approach seems to be the most acceptable.
Therefore, we need to be creative in the return value of the function to be able to insert either the result of normal computation or information about the error.
I proposed how this can be done in Java using the Result. In some article, I lamented the need to develop this class myself because it was missing from the standard Java library.
The good news is that the Kotlin language standard includes such a class, and it is also called Result.
An Annoying Misunderstanding
While exploring the description of the Result class’s functions, something caught my eye that initially deterred me from using this class. The signature (including the parameter names) of the function for setting error information looks like this:
fun <T> failure(
exception: Throwable
): Result<T>
If we rely on the name of the input parameter, it suggests that we can use this class’s function only with exception and not with our custom errors, such as input validation errors. However, let’s not rush and examine this more closely.
By reading the description of the Throwable class, we can see a member for describing the error message and the cause of the error. Both members are optional. Furthermore, there is an option to read or print the call stack.
Crucial information is found at the end of the class description:
Inheritors:
open class Error : Throwable
open class Exception : Throwable
Personally, I interpret this as follows: the language developers use Exception to encapsulate information about system errors and simultaneously suggest packing all other errors into the Error class or its subclasses. Moreover, both branches’ inheritors will be processed equally in the Result class’s failure(…) function and, of course, in all its other functions.
So, we can use the Result class when working with our custom error classes!
Well, let’s put our knowledge to the test. From my many years of experience in enterprise application development, I know that besides an error message, it’s very useful to include an error code or category, as well as some details (e.g., values of adjacent variables or the state of the environment). So, let’s create our error class:
/**
* Base class for representing business-specific errors.
*
* The `BusinessError` class provides a structured way to represent errors that occur within a business context.
* It includes properties such as error `code`, `message`, `details`, and an optional `cause` throwable.
*
* @param code The error code associated with the business error (optional).
* @param message A human-readable error message providing additional context (optional).
* @param details Additional details or information about the error (optional).
* @param cause The underlying cause of the error, such as an exception (optional).
*/
open class BusinessError(
val code: String? = null,
override val message: String? = null,
val details: String? = null,
override val cause: Throwable? = null
) : Error(message, cause)
Curious about whether when creating an instance (object) of this class, a call stack will be automatically set inside it? After all, it can be so useful when analyzing error situations! Let’s check (the full test text is here):
assertTrue(error.stackTrace[0].toString().startsWith("eu.sirotin.kotlin.error.ErrorTest"))
Hooray! It’s created!
Now let’s proceed to systematically explore the capabilities of the Result class.
What We Want and How to Achieve It
Our systematic study of the functions of this class will begin by trying to understand what we want from such a class.
So, let’s assume we are going to program a Kotlin function that calculates and returns a value. During the calculation, an error may occur. (We will consider a function that does not return a value later.)
I think we would like to be able to:
- Conveniently set a normal or error result within the function’s body.
- In the calling code of the function, easily determine whether it ran successfully or with an error.
- Obtain either a normal or error result for further use, or
- Perform handling for both situations.
The Result class functions that allow setting the result within a function and determining its content in the calling function are provided in the following table:
The remaining functions of the class are dedicated to processing the wrapped result instance (whether normal or error — in the form of error information). These functions, in general, cover most of the mental needs for such processing. These needs are listed in the first column of the table below.
If you are looking for a function that satisfies a particular need, you should look for candidates in the corresponding column (indicated by ‘¬’) that have ‘Y’ in the corresponding cell in the lower part of the table.”
Here is the table of functions for processing the Result class:
Next, we will look at typical examples of error handling using the Result class.
Setting the result within a function
In Kotlin, there is an inherited from Java extension function for strings that computes the integer representation of a string. This function is called as follows:
val n = “4”.toInt()
If the original string is not a textual representation of an integer, the function throws an Exception. Let’s create a function that doesn’t misbehave like this but instead returns either an integer or information about the exception as a result. Then, we’ll examine the usage rules of the Result class using this function as an example.
Novices in Kotlin, coming from Java, would probably write this function roughly like this:
/**
* Converts the current string to an integer safely, handling exceptions naively.
*
* This function attempts to convert the string to an integer using the [toInt] method. If successful,
* it returns the integer as a [Result.success]. If an exception occurs during the conversion,
* it returns a [Result.failure] containing the caught exception.
*
* @return A [Result] containing the integer value if the conversion is successful, or an error result
* if an exception occurs.
*/
private fun String.toIntSafeNaive(): Result<Int> {
return try {
// Attempt to convert the string to an integer.
val x = this.toInt()
// Return a success result with the integer value.
Result.success(x)
} catch (e: Exception) {
// Return a failure result with the caught exception.
Result.failure(e)
}
}
However, in Kotlin, there is a runCatching(…) function that allows you to achieve the same thing more elegantly:
/**
* Converts the current string to an integer safely using [runCatching].
*
* This function safely attempts to convert the string to an integer using [runCatching].
* It returns a [Result.success] containing the integer value if the conversion is successful,
* or a [Result.failure] containing the caught exception if an exception occurs.
*
* @return A [Result] containing the integer value if the conversion is successful, or an error result
* if an exception occurs.
*/
private fun String.toIntSafe(): Result<Int> = runCatching { this.toInt() }
Checking and “unpacking” the result
We check if the result is indeed the same and at the same time, see how the functions listed in the top table work:
/**
* Test case for comparing two realizations of the `toIntSafe` function.
*
* This test compares the behavior of two different realizations of the `toIntSafe` function,
* one using the `toIntSafeNaive` implementation and the other using the standard `toIntSafe` function.
* It validates their results in terms of success, failure, and exception messages.
*
* - It obtains two results for successful conversions from "21" to an integer, one from each implementation.
* - It compares the integer values obtained from both results.
* - It verifies that both results indicate success.
* - It checks that both results do not indicate failure.
* - It obtains two results for failed conversions from "21.1" to an integer, one from each implementation.
* - It compares the exception messages obtained from both results.
* - It verifies that both results indicate failure.
* - It checks that both results do not indicate success.
*/
@Test
fun `Compare realizations of Error toIntSafe`() {
// Obtain two results for successful conversions from "21" to an integer.
val resultSuccess1 = "21".toIntSafeNaive()
val resultSuccess2 = "21".toIntSafe()
// Compare the integer values obtained from both results.
assertEquals(resultSuccess1.getOrNull(), resultSuccess2.getOrNull())
// Verify that both results indicate success.
assertTrue(resultSuccess1.isSuccess)
assertTrue(resultSuccess2.isSuccess)
// Check that both results do not indicate failure.
assertFalse(resultSuccess1.isFailure)
assertFalse(resultSuccess2.isFailure)
// Check toString()
assertEquals("Success(21)", resultSuccess1.toString())
// Obtain two results for failed conversions from "21.1" to an integer.
val resultFailure1 = "21.1".toIntSafeNaive()
val resultFailure2 = "21.1".toIntSafe()
// Compare the exception messages obtained from both results.
assertEquals(resultFailure1.exceptionOrNull()?.message, resultFailure2.exceptionOrNull()?.message)
// Verify that both results indicate failure.
assertTrue(resultFailure1.isFailure)
assertTrue(resultFailure2.isFailure)
// Check that both results do not indicate success.
assertFalse(resultFailure1.isSuccess)
assertFalse(resultFailure2.isSuccess)
// Check toString()
assertEquals("Failure(java.lang.NumberFormatException: For input string: \"21.1\")", resultFailure1.toString())
In this example, we not only examined how the functions from the top table work but also used functions for the direct “unpacking’”of the result using getOrNull(…) and exceptionOrNull(…) functions.
Correcting an erroneous result
Very often, it happens that we can replace any erroneous result with a certain default value. In this case, our best friend is the getOrDefault(…) function:
/**
* Test case for using the `getOrDefault` function with `toIntSafe`.
*
* This test demonstrates the usage of the `getOrDefault` function with the `toIntSafe` function to handle
* default values in case of conversion failure.
*
* - It obtains a result for a successful conversion from "21" to an integer using `toIntSafe`.
* - It uses `getOrDefault` to retrieve the value from the result and provide a default value (12).
* - It verifies that the obtained value matches the expected integer (21).
* - It obtains a result for a failed conversion from "21.3" to an integer using `toIntSafe`.
* - It uses `getOrDefault` to retrieve the value from the result and provide a default value (12).
* - It verifies that the obtained value matches the provided default value (12).
*/
@Test
fun `Using getOrDefault`() {
// Obtain a result for a successful conversion from "21" to an integer using `toIntSafe`.
val result1 = "21".toIntSafe()
// Use `getOrDefault` to retrieve the value and provide a default value (12).
val resultValue1 = result1.getOrDefault(12)
// Verify that the obtained value matches the expected integer (21).
assertEquals(21, resultValue1)
// Obtain a result for a failed conversion from "21.3" to an integer using `toIntSafe`.
val result2 = "21.3".toIntSafe()
// Use `getOrDefault` to retrieve the value and provide a default value (12).
val resultValue2 = result2.getOrDefault(12)
// Verify that the obtained value matches the provided default value (12).
assertEquals(12, resultValue2)
}
If, in the event of an error, we always know how to set a new value in the erroneous situation, but this logic is non-trivial, we use the getOrElse(…) function:
/**
* Returns an error message based on the provided exception [exception].
*
* @param exception The exception that occurred during the operation.
* @return An error message based on the provided exception [exception].
*/
private fun getFalseValue(exception: Throwable): String {
//Expected format here: 'For input string: "1.1"'
val arr = exception.message!!.split("\"")
return "False format by ${arr[1]}"
}
/**
* Adds two strings representing integers and returns the result as a string.
*
* @param a The first input string.
* @param b The second input string.
* @return A string representing the sum of the integer values in [a] and [b], or an error message
* if the conversion or addition fails.
*/
private fun addAsString(a: String, b: String): String {
return runCatching {
"${a.toInt() + b.toInt()}"}
.getOrElse { e->getFalseValue(e) }
}
/**
* Test case for using the `getOrElse` function with the `addAsString` function.
*
* This test demonstrates the usage of the `getOrElse` function with the `addAsString` function
* to handle default values or alternative results.
*
* - It calls `addAsString` with valid inputs "1" and "2" and checks that the result is "3".
* - It calls `addAsString` with an invalid input "1" and "1.1" and checks that the result is "False format by 1.1".
*/
@Test
fun `Using getOrElse`() {
// Call `addAsString` with valid inputs "1" and "2" and check that the result is "3".
val result1 = addAsString("1", "2")
assertEquals("3", result1)
// Call `addAsString` with an invalid input "1" and "1.1" and check that the result is "False format by 1.1".
val result2 = addAsString("1", "1.1")
assertEquals("False format by 1.1", result2)
}
One of the most common scenarios is that in case of an error, we throw an exception, and we handle the normal result further. In this case, we use getOrThrow(…):
/**
* Test case for using the `getOrThrow` function with the `toIntSafe` function.
*
* This test demonstrates the usage of the `getOrThrow` function with the `toIntSafe` function
* to retrieve values or throw exceptions when working with results.
*
* - It uses `runCatching` to call `toIntSafe` with an invalid input "1.2" and attempts to retrieve the value.
* - It asserts that an exception of type `NumberFormatException` is thrown.
* - It uses `kotlin.runCatching` to call `toIntSafe` with a valid input "12" and attempts to retrieve the value.
* - It asserts that no exception is thrown, indicating a successful result.
*/
@Test
fun `Using getOrThrow`() {
// Use `runCatching` to call `toIntSafe` with an invalid input "1.2" and attempt to retrieve the value.
val result1 = runCatching { "1.2".toIntSafe().getOrThrow() }.exceptionOrNull()
// Assert that an exception of type `NumberFormatException` is thrown.
assertNotNull(result1)
assertIs<NumberFormatException>(result1)
// Use `kotlin.runCatching` to call `toIntSafe` with a valid input "12" and attempt to retrieve the value.
val result2 = kotlin.runCatching { "12".toIntSafe().getOrThrow() }.exceptionOrNull()
// Assert that no exception is thrown, indicating a successful result.
assertNull(result2)
}
Result Transformation
We can see that the approach of wrapping normal and erroneous results in a Result class instance is working reasonably well. The annoying part is that we have to unwrap the result to use it further. It turns out we saved in one place and pay for it in another.
This is only partially true. The Result class provides several convenient functions that allow us to transform one or both (normal and erroneous) values without prior unwrapping.
The first such function is map(…), which allows us to transform the normal result and leaves the erroneous one unchanged.
To demonstrate its capabilities, we will create a function that uses our toIntSafe(…) function and increases the value by 1 in a normal case without prior unwrapping.
/**
* Increases an integer value obtained from a string by 1.
*
* This function takes a string input [x], attempts to convert it to an integer using [toIntSafe],
* and then increases the resulting integer value by 1 using the `map` function from the [Result] class.
* The final result is a [Result] containing the incremented integer value.
*
* @param x The string representing an integer.
* @return A [Result] containing the incremented integer value or an error result if the conversion fails.
*/
private fun increase(x: String): Result<Int> {
return x.toIntSafe()
.map { it + 1 }
}
Let’s check how it works:
/**
* Test case for using the `map` function with the `increase` function.
*
* This test demonstrates the usage of the `map` function to increment an integer value obtained from a string.
*
* - It calls the `increase` function with the valid input "112" and retrieves the result.
* - It asserts that the result is 113, indicating a successful increment.
* - It calls the `increase` function with an invalid input "112.9" and attempts to retrieve the exception.
* - It asserts that an exception of type `NumberFormatException` is thrown, indicating a failed conversion.
*/
@Test
fun `Using map`() {
// Call the `increase` function with the valid input "112" and retrieve the result.
val result1 = increase("112").getOrNull()!!
// Assert that the result is 113, indicating a successful increment.
assertEquals(113, result1)
// Call the `increase` function with an invalid input "112.9" and attempt to retrieve the exception.
val result2 = increase("112.9").exceptionOrNull()!!
// Assert that an exception of type `NumberFormatException` is thrown, indicating a failed conversion.
assertNotNull(result2)
assertIs<NumberFormatException>(result2)
}
If we are concerned that an error may occur during the transformation, we should use the mapCatching(…) function. In this case, we need to check at the end whether an error occurred and, if so, whether it occurred before using the map(…) function or inside it.
To demonstrate this approach, we will create another function that adds values provided in text form:
/**
* Adds two integers obtained from strings and returns the result as a [Result].
*
* This function takes two string inputs [x] and [y], attempts to convert them to integers using [toIntSafe],
* and then adds the resulting integers. The operation is performed using the `mapCatching` function from the [Result] class.
* The final result is a [Result] containing the sum of the integers or an error result if the conversion or addition fails.
*
* @param x The first string representing an integer.
* @param y The second string representing an integer.
* @return A [Result] containing the sum of the integers or an error result if the conversion or addition fails.
*/
private fun add(x: String, y: String): Result<Int> {
return x.toIntSafe()
.mapCatching { it + y.toInt() }
}
Let’s check:
/**
* Test case for using the `mapCatching` function with the `add` function.
*
* This test demonstrates the usage of the `mapCatching` function to add two integers obtained from strings.
*
* - It calls the `add` function with valid inputs "112" and "38" and retrieves the result.
* - It asserts that the result is 150, indicating a successful addition.
* - It calls the `add` function with invalid inputs "112.9" and "38" and attempts to retrieve the exception.
* - It asserts that an exception of type `NumberFormatException` is thrown, and the error message contains "112.9".
* - It calls the `add` function with valid inputs "112" and "38.5" and attempts to retrieve the exception.
* - It asserts that an exception of type `NumberFormatException` is thrown, and the error message contains "38.5".
*/
@Test
fun `Using mapCatching`() {
// Call the `add` function with valid inputs "112" and "38" and retrieve the result.
val result1 = add("112", "38").getOrNull()!!
// Assert that the result is 150, indicating a successful addition.
assertEquals(150, result1)
// Call the `add` function with invalid inputs "112.9" and "38" and attempt to retrieve the exception.
val result2 = add("112.9", "38").exceptionOrNull()!!
// Assert that an exception of type `NumberFormatException` is thrown, and the error message contains "112.9".
assertNotNull(result2)
assertIs<NumberFormatException>(result2)
assertTrue(result2.message!!.contains("112.9"))
// Call the `add` function with valid inputs "112" and "38.5" and attempt to retrieve the exception.
val result3 = add("112", "38.5").exceptionOrNull()!!
// Assert that an exception of type `NumberFormatException` is thrown, and the error message contains "38.5".
assertNotNull(result3)
assertIs<NumberFormatException>(result3)
assertTrue(result3.message!!.contains("38.5"))
}
The functions recover(…) and recoverCatching(…) work similarly to map(…) and mapCatching(…), but they use the erroneous value. This can be useful if you can determine a new correct value based on error information.
/**
* Test case for using the `recover` function with the `toIntSafe` function.
*
* This test demonstrates the usage of the `recover` function to handle exceptions when working with results.
*
* - It uses `toIntSafe` to convert "-15" to an integer and then uses `recover` to retrieve the result or
* the stack trace size in case of an exception. It asserts that the result is -15.
* - It uses `toIntSafe` to convert "-15.1" to an integer and then uses `recover` to retrieve the result or
* the stack trace size in case of an exception. It asserts that the stack trace size is greater than 10.
*/
@Test
fun `Using recover`() {
// Use `toIntSafe` to convert "-15" to an integer and use `recover` to retrieve the result or the stack trace size.
val result1 = "-15".toIntSafe().recover { exception -> exception.stackTrace.size }
// Assert that the result is -15.
assertEquals(-15, result1.getOrNull())
// Use `toIntSafe` to convert "-15.1" to an integer and use `recover` to retrieve the result or the stack trace size.
val result2 = "-15.1".toIntSafe().recover { exception -> exception.stackTrace.size }
// Assert that the stack trace size is greater than 10.
assertTrue(result2.getOrNull()!! > 10)
}
/**
* Test case for using the `recoverCatching` function with the `toIntSafe` function.
*
* This test demonstrates the usage of the `recoverCatching` function to handle exceptions when working with results.
*
* - It uses `runCatching` to call `toIntSafe` with "-15.1" and then uses `recover` to convert "0.0" to an integer
* or retrieve an exception in case of a failure. It asserts that an exception of type `NumberFormatException` is thrown,
* and the error message contains "0.0".
* - It directly uses `recoverCatching` with "-15.1" and converts "0.0" to an integer. It asserts that an exception
* of type `NumberFormatException` is thrown, and the error message contains "0.0".
* - It directly uses `recoverCatching` with "-15" and converts "0.0" to an integer. It asserts that the result is -15.
* - It directly uses `recoverCatching` with "-15.1" and converts "2" to an integer. It asserts that the result is 2.
*/
@Test
fun `Using recoverCatching`() {
// Directly use `recoverCatching` with "-15.1" and convert "0.0" to an integer.
val result2 = "-15.1".toIntSafe().recoverCatching { "0.0".toInt() }.exceptionOrNull()
// Assert that an exception of type `NumberFormatException` is thrown, and the error message contains "0.0".
assertNotNull(result2)
assertIs<NumberFormatException>(result2)
assertTrue(result2.message!!.contains("0.0"))
// Directly use `recoverCatching` with "-15" and convert "0.0" to an integer.
val result3 = "-15".toIntSafe().recoverCatching { "0.0".toInt() }.getOrNull()
// Assert that the result is -15.
assertEquals(-15, result3)
// Directly use `recoverCatching` with "-15.1" and convert "2" to an integer.
val result4 = "-15.1".toIntSafe().recoverCatching { "2".toInt() }.getOrNull()
// Assert that the result is 2.
assertEquals(2, result4)
}
The fold(…) function allows you to combine both of the functions we discussed earlier into a single call:
/**
* Adds two strings representing integers and returns the result as a string.
*
* @param a The first input string.
* @param b The second input string.
* @return A string representing the sum of the integer values in [a] and [b], or an error message
* if the conversion or addition fails.
*/
private fun addAsString1(a: String, b: String): String {
return runCatching {
"${a.toInt() + b.toInt()}" }
.fold(
{"Result: $it"},
{getFalseValue(it)}
)
}
To improve the readability of your implementation, it is recommended to use parameter names for onSuccess and onFailure.
/**
* Adds two strings representing integers and returns the result as a string.
*
* @param a The first input string.
* @param b The second input string.
* @return A string representing the sum of the integer values in [a] and [b], or an error message
* if the conversion or addition fails.
*/
private fun addAsString2(a: String, b: String): String {
return runCatching {
"${a.toInt() + b.toInt()}" }
.fold(
onSuccess = {"Result: $it"},
onFailure = {getFalseValue(it)}
)
}
Let’s check:
/**
* Test case for using the `fold` function with two different implementations of `addAsString`.
*
* This test demonstrates the usage of the `fold` function to obtain results from two different implementations
* of the `addAsString` function and verifies their correctness.
*
* - It calls `addAsString1` with valid inputs "1" and "4" and checks that the result is "Result: 5".
* - It calls `addAsString2` with the same valid inputs and verifies that the result is also "Result: 5".
* - It calls `addAsString1` with an invalid input "1.3" and "4" and checks that the result is "False format by 1.3".
* - It calls `addAsString2` with the same invalid inputs and verifies that the result is also "False format by 1.3".
*/
@Test
fun `Using fold`() {
// Call `addAsString1` with valid inputs "1" and "4" and check that the result is "Result: 5".
val result1 = addAsString1("1", "4")
assertEquals("Result: 5", result1)
// Call `addAsString2` with the same valid inputs and verify that the result is also "Result: 5".
val result2 = addAsString2("1", "4")
assertEquals("Result: 5", result2)
// Call `addAsString1` with an invalid input "1.3" and "4" and check that the result is "False format by 1.3".
val result3 = addAsString1("1.3", "4")
assertEquals("False format by 1.3", result3)
// Call `addAsString2` with the same invalid inputs and verify that the result is also "False format by 1.3".
val result4 = addAsString2("1.3", "4")
assertEquals("False format by 1.3", result4)
}
The same result can be achieved by calling onSuccess(…) and onFailure(…) functions sequentially. The charm of this approach is that the result does not depend on the order in which these functions are called:
/**
* Test case for using the `onSuccess` and `onFailure` functions with `toIntSafe`.
*
* This test demonstrates the usage of the `onSuccess` and `onFailure` functions with the `toIntSafe` function
* to handle success and failure cases and update a result variable.
*
* - It uses `toIntSafe` to convert "3.5" to an integer and uses `onFailure` to set the result to "FAILURE".
* - It asserts that the result variable is "FAILURE" since the conversion fails.
* - It uses `toIntSafe` to convert "3.5" to an integer and uses `onSuccess` to set the result to "SUCCESS".
* - It again uses `onFailure`, but this time after `onSuccess`, and asserts that the result remains "FAILURE"
* - It uses `toIntSafe` to convert "35" to an integer and uses `onFailure` to set the result to "FAILURE".
* - It asserts that the result variable is "SUCCESS" since the conversion is successful and `onFailure` is not called.
* - It uses `toIntSafe` to convert "35" to an integer and uses `onSuccess` to set the result to "SUCCESS".
* - It again uses `onFailure`, but this time after `onSuccess`, and asserts that the result remains "SUCCESS"
* since `onFailure` will be not called.
*/
@Test
fun `Using onSuccess and onFailure`() {
// Initialize the result variable.
var result = ""
// Use `toIntSafe` to convert "3.5" to an integer and use `onFailure` to set the result to "FAILURE".
"3.5".toIntSafe()
.onFailure { result = FAILURE }
// Assert that the result variable is "FAILURE" since the conversion fails.
assertEquals(FAILURE, result)
// Use `toIntSafe` to convert "3.5" to an integer and use `onSuccess` to set the result to "SUCCESS".
// but it should pass.
"3.5".toIntSafe()
.onSuccess { result = SUCCESS }
.onFailure { result = FAILURE }
// Assert that the result variable remains "FAILURE" `.
assertEquals(FAILURE, result)
result = SUCCESS
// Use `toIntSafe` to convert "35" to an integer and use `onFailure` to set the result to "FAILURE".
"35".toIntSafe()
.onFailure { result = FAILURE }
// Assert that the result variable is "SUCCESS" since the conversion is successful and the action
// of `onFailure` is not called.
assertEquals(SUCCESS, result)
// Use `toIntSafe` to convert "35" to an integer and use `onSuccess` to set the result to "SUCCESS".
// Then, use `onFailure`, but it should not override the previous `onSuccess` value.
"35".toIntSafe()
.onSuccess { result = SUCCESS }
.onFailure { result = FAILURE }
// Assert that the result variable remains "SUCCESS" since `onFailure` does not override the previous `onSuccess`
//because it action was not called
assertEquals(SUCCESS, result)
}
Error handling in “resultless” functions
In the examples discussed above, we looked at the use of the Result class in functions that return a result. But what if, from a business logic perspective, the function does not return any result? For example, writing to a database or sending data over a communication channel usually ends well, but sometimes it fails. How do you handle errors without throwing an exception in this case?
One good approach is to make the function optionally return error information, as shown in the example below.
But first, let’s define two helper functions that we will need in the following tests:
/**
* Dummy function for an action that should be executed when a value is available.
* This function is used to fix the call structure and set the 'actionWithValueExecuted' flag to true.
*
* @param ignoredValue The integer value (ignored) for which the action is performed.
*/
private fun someActionWithValue(ignoredValue: Int) {
actionWithValueExecuted = true
}
/**
* Dummy function for an action that should be executed when an error occurs.
* This function is used to fix the call structure and set the 'actionWithErrorExecuted' flag to true.
*
* @param ignoredError The Throwable (ignored) representing the error condition for which the action is performed.
*/
private fun someActionWithError(ignoredError: Throwable) {
actionWithErrorExecuted = true
}
Now, here’s the modified resultless” function and a test for it:”
/**
* Processes an integer value represented as a string, following the strategy of
* "make something by success and throw by failure."
*
* @param value The string representation of an integer value.
* @return A Throwable instance if there is a conversion failure, otherwise null.
*/
private fun processIntValue(value: String): Throwable? =
value.toIntSafe()
.onSuccess { someActionWithValue(it) }
.exceptionOrNull()
/**
* Test case to demonstrate processing an integer value as a string and handling success and failure.
*/
@Test
fun `Using pseudo result-less`() {
// When "25" is processed, it should result in a null Throwable, indicating success.
assertNull(processIntValue("25"))
assertTrue(actionWithValueExecuted)
// Reset the action flag for the next test.
actionWithValueExecuted = false
// When "25.9" is processed, it should result in a non-null Throwable, indicating a failure.
assertNotNull(processIntValue("25.9"))
assertFalse(actionWithValueExecuted)
}
“Triple” Results
Sometimes there are situations where you need to check if a function can be applied to a given set of parameters first, and only then apply it. For example, in parsing or data validation, this check and the actual function application intersect in the code, and the checking part is significantly more computationally expensive than other actions.
In this case, we can talk about a “triple” result of the function:
1. A normal result.
2. Error information.
3. Information that the function is not applicable to the given set of parameters.
Imagine that we need to implement a function that, given a text value, expecting it to be an integer, returns its sign “+” or “‑”, and in case of 0, it is considered that the function is not applicable. Of course, the input parameter is a string and may not represent any integer.
One possible approach in this case is to pack the first two alternatives as before into a Result, but to indicate the tripleness, make the return value optional (possibly null):
/**
* Attempts to extract the sign of an integer from the current [String].
*
* @return A [Result] containing the sign of the integer as a [String] ("+" or "-"), or `null` by zero
* if the operation is not applicable (e.g., for non-integer strings) [Result] contains exception.
*/
private fun String.signOfInt(): Result<String>? {
// Try to convert the string to an integer using the toIntSafe() extension function.
val result = this.toIntSafe()
// If the conversion fails, return a failure result with the exception.
if (result.isFailure) return Result.failure(result.exceptionOrNull()!!)
// Determine the sign of the integer and return it as a success result.
return when (result.getOrNull()!!.sign) {
1 -> Result.success("+")
-1 -> Result.success("-")
else -> null
}
}
/**
* Test case for using the `signOfInt` function with result-less return.
*
* This test demonstrates the usage of the `signOfInt` function to obtain results and exceptions in scenarios where
* the function may return a result or no result (`null`) based on the input.
*
* - It calls the `signOfInt` function with an invalid input "1.1" and uses `exceptionOrNull` to retrieve the exception.
* It asserts that an exception of type `NumberFormatException` is thrown, and the error message contains "1.1".
* - It calls the `signOfInt` function with a valid input "0" and expects the result to be `null` since "0" has no sign.
* - It calls the `signOfInt` function with valid inputs "+11" and "-112" and uses `getOrNull` to retrieve the result.
* It asserts that the results are "+", and "-", respectively.
*/
@Test
fun `Using not-applicable`() {
// Call the `signOfInt` function with an invalid input "1.1" and retrieve the exception.
val result1 = "1.1".signOfInt()?.exceptionOrNull()
// Assert that an exception of type `NumberFormatException` is thrown, and the error message contains "1.1".
assertNotNull(result1)
assertIs<NumberFormatException>(result1)
assertTrue(result1.message!!.contains("1.1"))
// Call the `signOfInt` function with a valid input "0" and expect the result to be `null` since "0" has no sign.
assertNull("0".signOfInt())
// Call the `signOfInt` function with valid inputs "+11" and "-112" and retrieve the results.
val result2 = "+11".signOfInt()?.getOrNull()
val result3 = "-112".signOfInt()?.getOrNull()
// Assert that the results are "+", and "-", respectively.
assertEquals("+", result2)
assertEquals("-", result3)
}
Multi-Step Use Cases for Result
Result class functions can be used in chains and cascades of calls.
Below we will look at a couple of the most typical ones.
The first scenario involves staying in a chain of processing or calculations at the first encountered error. In this case, in case of an error, we want to know at which stage it occurred.
Let’s say we want to calculate the value using the formula:
y = ax² + bx + c
In this case, a, b, and c are given as strings, and each string can contain an error. Here’s a possible solution:
/**
* Calculates the expression ax^2 + bx + c for given values of x, a, b, and c.
* This function employs the strategy of "stopping normal processing by the first failure in the chain."
*
* @param x The value of 'x' in the equation.
* @param a The coefficient 'a' as a String.
* @param b The coefficient 'b' as a String.
* @param c The coefficient 'c' as a String.
* @return A Result<Int> representing the result of the calculation or an exception if any of the conversions fail.
*/
private fun `calculate ax2 + bx + c`(x: Int, a: String, b: String, c: String): Result<Int> =
runCatching { a.toInt() * x * x }
.mapCatching { it + b.toInt() * x }
.mapCatching { it + c.toInt() }
/**
* Test case to demonstrate chained calculations and catching the first failure.
*/
@Test
fun `Using chained call with catching first failure`() {
// Calculate the expression successfully with valid inputs.
assertEquals(6, `calculate ax2 + bx + c`(1, "1", "2", "3").getOrNull()!!)
// Attempt to calculate with 'a' as a non-integer should result in an exception.
assertTrue(`calculate ax2 + bx + c`(1, "1.1", "2", "3").exceptionOrNull().toString().contains("1.1"))
// Attempt to calculate with 'b' as a non-integer should result in an exception.
assertTrue(`calculate ax2 + bx + c`(1, "1", "2.2", "3").exceptionOrNull().toString().contains("2.2"))
// Attempt to calculate with 'c' as a non-integer should result in an exception.
assertTrue(`calculate ax2 + bx + c`(1, "1", "2", "3.3").exceptionOrNull().toString().contains("3.3"))
}
The second typical scenario is when we deal with errors “until the end.” This happens when we read “dirty” source data, but can only pass on “clean” data for further processing.
In the example below (this is the last example! :-), when converting a string to an integer, in case of failure with direct conversion, we assume that the string represents a Double, and if that doesn’t help either, we will return the maximum integer value:
/**
* Converts a string to an integer using a strategy of "trying many approaches to get a value."
* This function first attempts to convert the string to an integer safely. If that fails, it tries
* to convert the string to a double and then to an integer. If both conversions fail, it returns
* Int.MAX_VALUE.
*
* @return An integer value obtained from the string or Int.MAX_VALUE if all conversion attempts fail.
*/
private fun String.toIntAnyway(): Int =
this.toIntSafe()
.recoverCatching { this.toDouble().toInt() }
.getOrDefault(Int.MAX_VALUE)
/**
* Test cases to demonstrate the "try many approaches to get a value" strategy.
*/
@Test
fun `Using chained call anyway strategy`() {
// Successfully convert "2" to an integer.
assertEquals(2, "2".toIntAnyway())
// Convert "2.2" to an integer after trying the double conversion.
assertEquals(2, "2.2".toIntAnyway())
// Convert "23.81e5" to an integer after trying the double conversion.
assertEquals(2381000, " 23.81e5".toIntAnyway())
// Conversion fails for " Very match," so it returns Int.MAX_VALUE.
assertEquals(Int.MAX_VALUE, " Very match".toIntAnyway())
}
Instead of a conclusion
If you’ve read this guide to this point, I hope you now understand that when handling errors in Kotlin, there’s no need to create your own classes or adapt the Either class from the Arrow library for this purpose. Just use what’s already in the language correctly — the Result class.
I’d like to remind you that you can find the source code for this article here.
I hope this article has helped you form the right mental models for error handling in Kotlin. If the topic of mental models in programming interests you, you might also find this article interesting. And if you find them intriguing, take a look or even join this Telegram group (it’s in Russian).
Additionally, I’m writing a free book (also in Russian) called ‘Memoirs of a Nomadic Programmer: Tales, Truths, Thoughts.’ If you have the time and the inclination, I invite you to browse its pages.
The author wanted to illustrate in the article that inefficient handling occurs not only in programming but also in other contexts.