1. Introduction

Kotlin offers a variety of features to enhance code readability, maintainability, and robustness. One such feature that plays a crucial role in error handling is the Result type.

In this tutorial, we’ll explore what the Result class is, why it’s important, and how to use it effectively in Kotlin. Additionally, we’ll discuss practical use cases, providing code snippets along with their corresponding JUnit test cases.

2. Understanding Result

The Result type is a part of the Kotlin standard library. It was introduced to handle the outcome of operations that can either succeed or fail. There are two ways to create an instance of a Result. To represent a successful result, we should use Result.success(), and to represent a failure, we should use Result.failure().

Using a Result object in Kotlin instead of exceptions aligns with functional programming principles. It ensures explicit handling of successes and failures, promoting predictability and code robustness. Unlike exceptions, Result objects encourage developers to handle errors explicitly, contributing to clearer and more controlled error management.

Additionally, Result objects simplify testing and fit well with functional composition practices, allowing for more modular and flexible code design. While exceptions have their place, especially for unrecoverable errors, Result objects provide a functional alternative for cases where controlled error handling and composability are priorities.

2.1. Why Is Result Important?

The significance of the Result type in Kotlin lies in its promotion of explicit error handling. By compelling developers to address both success and failure cases conscientiously, Result minimizes the risk of overlooking potential errors, aiding the development of more robust applications.

Furthermore, Result offers an advantage over nullable return types, offering an intentional way of representing success or failure. This not only enhances code readability but also ensures transparent code, thus facilitating a better understanding of the program’s flow and making it easier for developers to reason about the code.

3. Result Class With runCatching()

Before exploring the manual creation of Result instances, we’ll explore the provided utility function runCatching(). This function accepts a lambda that could throw an exception and will return a Result.failure() with the exception if it does. Otherwise, it’ll return a Result.success() with the return value of the lambda:

fun divide(a: Int, b: Int): Result {
    return runCatching {
        a / b
    }
} 

In our code above, divide() throws an ArithmeticException if the divisor b is zero. The runCatching() function is an alternative to the try/catch block for handling exceptions manually.

Now, let’s test both a successful and failed result from our divide() function:

@Test
fun `should handle successful division`() {
    val resultValid = divide(10, 2)
    assertTrue(resultValid.isSuccess)
    assertEquals(5, resultValid.getOrNull())
}

@Test
fun `should handle division by zero`() {
    val resultInvalid = divide(5, 0)
    assertTrue(resultInvalid.isFailure)
    assertEquals(ArithmeticException::class, resultInvalid.exceptionOrNull()!!::class)
    assertEquals("/ by zero", resultInvalid.exceptionOrNull()!!.message)
}

4. How to Use Result

While we’re writing code that we know has specific failure conditions up front, we can also directly create Result instances ourselves:

fun divide(a: Int, b: Int): Result<Int> = if (b != 0) {
    Result.success(a / b)
} else {
    Result.failure(Exception("Division by zero is not allowed."))
}

We’ve already established that we can’t divide by zero. Instead of letting division by zero throw an exception, we check the divisor in our divide() function and directly return a Result.failure() with an exception and error message if it is zero. Otherwise, we can perform the division and return the value in a Result.success().

Let’s test both paths of our divide() function again:

@Test
fun `should test valid division`() {
    val firstResult = divide(10, 2)
    assertEquals(Result.success(5), firstResult)
}

@Test
fun ` should handle division by zero`(){
    val result = divide(10, 0)
    val expectedException = assertFailsWith<Exception> {
        result.getOrThrow()
    }
    assertEquals("Division by zero is not allowed.", expectedException.message)
}

4.1. Handling Successful Cases With the Result Class

Let’s take a look at a case when we have a successful Result. We’re able to confirm that we are working with a successful result, and we have multiple ways to unwrap the value itself:

@Test
fun `Should handle Successful States`() {
    val result = Result.success(42)
    assertTrue(result.isSuccess)
    assertEquals(42, result.getOrNull())
    result.onSuccess {
        assertEquals(42, it)
    }
}

In the code above, we can verify that the Result is successful by checking the isSuccess property. We can also get the value from the Result with the getOrNull() function. Lastly, we can even use the successful result as the subject of a lambda function with onSuccess(), which will only be called when the Result is successful.

4.2. Handling Failure Cases With the Result Class

Now, let’s consider a case where we have a failure Result. We’ll focus on confirming the failure and inspecting the associated exception:

@Test
fun `Should handle Failure States`() {
    val result = Result.failure<Int>(Exception("We have an error!"))
    assertTrue(result.isFailure)
    assertNotNull(result.exceptionOrNull())
    result.onFailure {
        assertEquals("We have an error!", it.message)
    }
}

Our test confirms that the Result is a failure with the isFailure property. We can extract the specific error from the Result with the exceptionOrNull() function. Finally, we can perform additional logic with the error with the onFailure() function, which is only called on a failure Result.

5. Conclusion

The Result type in Kotlin is a valuable tool for enhancing code reliability and readability when dealing with error handling. We’ve seen how to use runCatching() as an alternative to the try/catch and how it returns a Result. Then, we looked at how we can directly create instances of Result objects to explicitly handle errors. Lastly, we learned how to unwrap both success and failure objects returned in the Result.

By explicitly representing success and failure outcomes, developers can build more robust applications that gracefully handle potential issues.

As always, the full implementation of these examples is available over on GitHub.

Comments are closed on this article!