article banner (priority)

Item 7: Prefer a nullable or Result result type when the lack of a result is possible

This is a chapter from the book Effective Kotlin. You can find it on LeanPub or Amazon.

Sometimes a function cannot produce its desired result. A few common examples are:

  • We try to get data from a server, but there is a problem with the internet connection.
  • We try to get the first element that matches some criteria, but there is no such element in our list.
  • We try to parse an object from text, but this text is malformatted.

There are two main mechanisms to handle such situations:

  • Return a null or Result.failure, thus indicating failure.
  • Throw an exception.

There is an important difference between these two. Exceptions should not be used as a standard way to pass information. All exceptions indicate incorrect, special situations and should be treated this way. We should use exceptions only for exceptional conditions (Effective Java by Joshua Bloch). The main reasons for this are:

  • The way exceptions propagate is not very readable for most programmers and might easily be missed in code.
  • In Kotlin, all exceptions are unchecked. Users are not forced or even encouraged to handle them. They are often not well documented, and they are not very visible when we use an API.
  • Because exceptions are designed for exceptional circumstances, there is little incentive for JVM implementers to make them as fast as explicit tests.
  • Placing code inside a try-catch block inhibits certain optimizations that the compiler might otherwise perform.

On the other hand, null or Result.failure are both perfect for indicating an expected error. They are explicit, efficient, and can be handled in idiomatic ways. This is why the rule is that we should prefer to return null or Result.failure when an error is expected, and we should throw an exception when an error is not expected. Here are some examples:

inline fun <reified T> String.readObjectOrNull(): T? { //... if (incorrectSign) { return null } //... return result } inline fun <reified T> String.readObject(): Result<T> { //... if (incorrectSign) { return Result.failure(JsonParsingException()) } //... return Result.success(result) } class JsonParsingException : Exception()

Errors indicated like this are easier to handle and harder to miss. When we choose to use null, clients handling such a value can choose from a variety of null-safety-supporting features like a safe-call or the Elvis operator:

val age = userText.readObjectOrNull<Person>()?.age ?: -1

When we choose to return Result, the user of this function will be able to handle it using methods from the Result class:

userText.readObject<Person>() .onSuccess { showPersonAge(it) } .onFailure { showError(it) }

Using such error handling is not only more efficient than a try-catch block but is often also easier to use and more explicit. An exception can be missed and can stop our whole application; in contrast, a null value or a Result object needs to be explicitly handled, and it won’t interrupt the flow of the application.

The difference between a nullable result and the Result object is that we should prefer the latter when we need to pass additional information in the case of failure; otherwise, we should prefer null.

The Result class has a rich API of methods you can use to handle your result, including:

  • isSuccess and isFailure properties, which we use to check if the result represents a success or a failure (isSuccess == !isFailure is always true).
  • onSuccess and onFailure methods, which call their lambda expressions when the result is, respectively, a success or a failure.
  • getOrNull method, which returns the value if the result is a success, or null otherwise.
  • getOrThrow method, which returns the value if the result is a success, or throws the exception from the failure otherwise.
  • getOrDefault method, which returns the value if the result is a success, or the default value provided as an argument if the result is a failure.
  • getOrElse method, which returns the value if the result is a success, or calls its functional argument and returns its result.
  • exceptionOrNull method, which returns the exception if the result is a failure, or null otherwise.
  • map method for transforming the success value.
  • recover method for transforming a throwable value into a success value.
  • fold method for handling both success and failure in a single method.

To transform a function that throws exceptions into one that returns Result, use runCatching instead.

fun getA(): Result<T> = runCatching { getAThrowing() }

In important parts of an API, you might define two variants of functions: one that expects that failure can occur, and one that treats it as an unexpected situation. A good example is the fact that List has both:

  • get, which is used when we expect an element to be at the given position; if it is not there, the function throws IndexOutOfBoundsException.
  • getOrNull, which is used when we suppose that we might ask for an element that is out of range; if that happens, we’ll get null.

List also supports other options, like getOrDefault, which is useful in some cases but in general might be easily replaced with getOrNull and the Elvis operator ?:.

This is good practice because if developers know that an element must be at the specific position, they should not be forced to handle a nullable value; if they have any doubts, they should use getOrNull and properly handle the lack of a value.