1. Overview

As Kotlin developers, we’ve all been throwing and catching exceptions for handling anomalous conditions in our programs. This looked familiar, and the only way to go for modeling error handling logic. However, handling failures this way doesn’t scale very well with our programs’ complexity.

In this tutorial, we’ll learn about functional error handling patterns in Kotlin.

2. Throwing and Catching Exceptions

Exceptions are failure events breaking a thread’s normal execution flow. Exceptions can arise because of programming errors, but also because of infrastructural or network outages that are completely out of our control.

A common problem is understanding if thrown exceptions are captured, and then, how they are going to be recovered.

2.1. Unstructured Error Handling Logic

We raise exceptions using the throw keyword, as opposed to returning them as values with an explicit or implicit return. However*,* this practice inherently violates structured programming principles*.* 

Upon throwing an exception, a thread’s execution flow simply stops being linear. We then need to inspect the code base to understand the error handling logic.

Besides, Kotlin’s lack of transparency on the generated failures makes error handling logic even more difficult to reason about.

2.2. Lack of Transparency

Java’s checked exceptions represented recoverable failures. We had to declare them in method signatures. At the same time, the general recommendation was to ignore unchecked exceptions, essentially because they were considered failures we couldn’t guard against.

Things in Kotlin are different. All Kotlin exceptions are unchecked. The language doesn’t provide any strict equivalent for Java’s throws keyword.

Kotlin strives for functional programming, but identifying possible failures requires knowledge of the internals of a function. Unfortunately, dependency on implementation details defeats the whole purpose of functional programming itself.

3. Functional Error Handling

When applied to Kotlin, the essential idea of functional error handling is that non-fatal exceptions should be considered possible results of a function or method call.

If recoverable failures become function results, then their compensation logic must enter the standard execution flow of our program. The error handling programming model gets structured back again. Then, only fatal exceptions will short-circuit execution flows. This is acceptable because there’s nothing we can do to guard against them anyway.

To implement functional error handling, we need a way to represent errors along with intended results and the capability to catch all but fatal exceptions in every function or method definition.

4. Representing Errors Along With Intended Results

There are two alternatives for representing errors along with intended results.

First, we can represent errors simply through the absence of the intended result. In this case, we assume recovery logic won’t need any details on generated errors. We’ll see how to implement this approach using Kotlin nullable types, or by using Arrow’s Option type.

Alternatively, we can use a container type for returning either generated errors or the intended result of a computation. The container type interface has consequences on the clarity of the resulting code – handling errors at every call site implies continuous type checking on returned values. We’ll see implementations of this approach using sealed hierarchies, Arrow’s Either monad, or the standard library’s Result.

4.1. Setup

Let’s prepare our environment with minimal Maven dependencies for driving the examples in the following sections:

<dependency>
    <groupId>io.arrow-kt</groupId>
    <artifactId>arrow-core</artifactId>
    <version>1.2.0</version>
</dependency>

The latest version of these libraries can be found on Maven Central.

4.2. Catch All but Fatal Exceptions

We can safely consider Error, and its subclasses (e.g., OutOfMemoryError), as fatal exceptions. We should also consider CancellationException a fatal exception since it can short-circuit suspendable computations.

Functional error handling then needs “builder functions” to consistently discriminate fatal from recoverable exceptions and to package recoverable errors or intended results into function outputs.

We’ll learn about builder functions in the following sections. As we’ll see, they all share a common structure.

4.3. Using Nullable Types

We can use Kotlin nullable types to represent errors as missing intended results. We have to simply use null-safety capabilities already provided by the language, without introducing any additional dependency.

Let’s define a builder function supporting this pattern:

inline fun <T> nullable(f: () -> T): T? = try {
        f()
    } catch (e: CancellationException) {
        throw e
    } catch (e: Error) {
        throw e
    } catch (e: Throwable) {
        null
    }

The nullable builder only throws fatal exceptions, and it returns null output for any recoverable error.

To see the builder in action, let’s define a dummy findOrder() function:

fun findOrder(id: Int): Order? = nullable {
    if(id > 0) Order(id) else throw Exception("Order Id must be a positive number")
}

We can then use this function by following common Kotlin patterns:

val orderId: Int = findOrder(-1)?.id ?: -1

We should keep in mind that this approach can lead to code cluttering. At every call site, we’ll be forced to handle null values. Also, safety check operators will be scattered all over our code base.

4.4. Using Arrow’s Option

We can also use Arrow’s Option type to represent errors as missing intended results:

fun findOrder(id: Int): Option<Order> = catch {
    if(id > 0) Order(id) else throw Exception("Order Id must a positive number")
}

Here, we’re using the catch builder function that ships along Option‘s companion object. Option‘s catch already distinguishes fatal from recoverable exceptions, so we don’t have to roll out a builder function on our own.

We can then use pattern matching on the Option returned by findOrder():

val orderId = when(val option = findOrder(1)) {
    is Some -> option.value.id
    else -> -1
}

4.5. Using Sealed Hierachies

Kotlin doesn’t support union types, but we can approximate them through sealed hierarchies. Let’s consider a Container type:

sealed class Container<out L, out R> {

    data class Failure<L> (val value: L) : Container<L, Nothing>()

    data class Success<R> (val value: R) : Container<Nothing, R>()

    companion object {
        inline fun <R> catch(block: () -> R): Container<Throwable, R> {
            return try {
                Success(block())
            } catch (e: CancellationException) {
                throw e
            } catch (e: Error) {
                throw e
            } catch (e: Throwable) {
                Failure(e)
            }
        }
    }
}

Container is one of the simplest approximations to a union type we can think of. Container can only be a Success or a Failure. Generics parameters L and R identify the value type for the failure and success path. They are out parameters, for Container isn’t a consumer of those values. The catch builder function is defined within the Container‘s companion object.

Let’s tweak our findOrder() dummy function to support Container:

fun findOrder(id: Int): Container<Throwable, Order> = Container.catch {
    if(id > 0) Order(id) else throw Exception("Order Id must be a positive number")
}

The sealed Container hierarchy now allows pattern matching on *findOrder()*‘s result:

when(val container = findOrder(1)){
    is Container.Success -> {
        // Order found logic here
    }
    is Container.Failure -> {
        // Order not found recovery logic here
    }
}

As it’s visually clear, we’ll end up specifying logic for each of the possible Container’s “rails”. Should our logic require composition with other functions returning a Container, we’d then introduce more nesting in our code, up to an unmanageable situation.

To see this possibility, let’s introduce a dummy findCustomer() function:

fun findCustomer(id: Int): Container<Throwable, Customer> = Container.catch {
    if (id == 1) Customer(id, "[email protected]") else throw Exception("Cannot find any customer for id $id")
}

Let’s see what would happen when retrieving customer data using the Order‘s customerId:

when (val container = findOrder(1)) {
    is Container.Success -> when (val customerContainer = findCustomer(container.value.customerId)) {
        is Container.Success -> {
            // Customer found logic here
        }
        is Container.Failure -> {
            // Customer not found recovery logic here
        }
    }
    is Container.Failure -> {
        // Order not found recovery logic here
    }
}

Things would get worse for any additional composition, no matter how we decide to refactor our code. We achieved structured programming at the cost of increasing code cluttering. We see how this approach doesn’t scale well with the complexity of our logic.

4.6. Using Arrow’s Either

Either is a functional type that represents a value out of two possible data types. By convention, these types are called the left type and right type, with the left one representing the exceptional result.

Let’s code the findOrder() and findCustomer() functions using Arrow’s Either implementation:

fun findOrder(id: Int): Either<Throwable, Order> = Either.catch {
    if (id > 0) Order(id, 1) else throw Exception("Order Id must be a positive number")
}

fun findCustomer(id: Int): Either<Throwable, Customer> = Either.catch {
    if (id == 1) Customer(id, "[email protected]") else throw Exception("Cannot find any customer for id $id")
}

The Either.catch builder function already distinguishes fatal from recoverable exceptions, so we don’t have to bother about defining another builder function.

As opposed to our simple Container, Arrow implements Either as a monad. We can see how Either‘s monadic nature simplifies findOrder() and findCustomer() composition:

findOrder(1)
  .map(Order::customerId)
  .flatMap(::findCustomer)
  .map(Customer::email)

The code is now much more visually appealing, so we can push on the composition complexity while keeping cluttering under control.

4.7. Using the Standard Library Result

We can think of Kotlin’s standard library Result as a restricted Either type, where the Left generic parameter is constrained to Throwable.

Since Kotlin 1.5, functions can return Result. It’s common to use the standard library’s runCatching as Result‘s builder function. However, runCatching doesn’t distinguish fatal from recoverable exceptions, so both Error and CancellationException would be caught if we use that function.

We’ll roll out a custom builder function to provide the desired behavior:

inline fun <T> Result.Companion.catch(f: () -> T): Result<T> {
    return try {
        success(f())
    } catch (e: CancellationException) {
        throw e
    } catch (e: Error) {
        throw e
    } catch (e: Throwable) {
        failure(e)
    }
}

Result provides helpers for value transformations, but it doesn’t comply with monad laws. Indeed, Result doesn’t provide any flatMap() operation for composing functions returning a Result as well.

There have been interesting discussions about this point, but in the end, providing such an additional capability with an extension function is relatively easy:

inline fun <A, B> Result<A>.flatMap(f: (A)-> Result<B>): Result<B> = when(isFailure) {
    true -> failure(exceptionOrNull()!!)
    false ->  f(getOrNull()!!)
}

With this setup in place, let’s see the findOrder() and findCustomer() functions:

fun findOrder(id: Int): Result<Order> = Result.catch {
    if (id > 0) Order(id, 1) else throw Exception("Order Id must be a positive number")
}

fun findCustomer(id: Int): Result<Customer> = Result.catch {
    if (id == 1) Customer(id, "[email protected]") else throw Exception("Cannot find any customer for id $id")
}

Our extensions promote Result to a monad, and they allow some of the Either type benefits without requiring any additional dependency. Indeed, findOrder() and findCustomer() composition looks exactly the same as that using Either:

findOrder(1)
  .map(Order::customerId)
  .flatMap(::findCustomer)
  .map(Customer::email)

However, we can’t remap Throwable to anything, as Result assumes our program won’t consider alternative error representations. This can only be a problem if we are planning to map exceptions to domain concepts to improve our code transparency.

5. Typed Errors

In theory, the exceptional result type doesn’t forcibly need to be a Throwable. For example, within a hexagonal architecture domain layer, we may prefer to directly represent errors through domain concepts. When working at the infrastructural layer, we may still decide to map Throwable instances to a closed error hierarchy. Indeed, we may desire to enable pattern matching on errors or to make our code more transparent for others.

5.1. Declaring Typed Errors

We can declare typed errors through sealed hierarchies. Within our simple Order and Customer domain, let’s declare a sealed error hierarchy:

sealed class DomainError {
    data class OrderNotFound(val id: Int): DomainError()

    data class CustomerNotFound(val id: Int): DomainError()
}

5.2. Replacing Exceptions With Typed Errors

Let’s now reconsider the findOrder() and findCustomer() functions we’ve used with Either, and introduce our DomainError hierarchy:

fun findOrder(id: Int): Either<DomainError, Order> = either {
    if (id < 0) raise(DomainError.OrderNotFound(id))
    Order(id, 1)
}

fun findCustomer(id: Int): Either<DomainError, Customer> = either {
    if (id != 1) raise(DomainError.CustomerNotFound(id))
    Customer(id, "[email protected]")
}

We can see a couple of differences.

Firstly, the builder function has changed from catch to either, for the new implementation is completely bypassing local exception handling. We’re not capturing exceptions anymore. We could end up violating the basic functional error handling principle, and let unwanted exceptions bubble up the stack.

Secondly, we’re short-circuiting the computation through raise(), an Arrow function especially built for this purpose, as opposed to throwing exceptions.

5.3. Mapping Captured Exceptions to Typed Errors

When we can’t assume we’re free from runtime exceptions, as in the infrastructural layer of an application, we can still introduce typed errors by remapping captured exceptions:

fun findOrderWithDomainError(id: Int): Either<DomainError, Order> = findOrder(id)
  .mapLeft {
      DomainError.OrderNotFound(id)
  }

fun findCustomerWithDomainError(id: Int): Either<DomainError, Customer> = findCustomer(id)
  .mapLeft {
      DomainError.CustomerNotFound(id)
  }

Either‘s mapLeft() is the way to go for remapping captured exceptions to the right DomainError. However, remapping exceptions to a custom error hierarchy isn’t always as simple as that. Sometimes, remapping isn’t possible, or at least it can require some severe revision of our errors’ classification.

6. Forcing Return Handling

Many libraries out there, including many Spring components, expect an exception to be thrown sooner or later for triggering any recovery logic.

Within functional error handling, containers hide failure events from the rest of our code. We must remember to always handle containers exceptional values according to the requirements of our frameworks. In some cases, we may find ourselves re-throwing a container’s exception just to satisfy the needs of our infrastructure.

There’s a gotcha here. Kotlin doesn’t force the handling of function results. There is an ongoing discussion about this feature, so things may change in the future, but until then, we have to propagate needed exceptions by ourselves.

7. Conclusion

In this article, we learned how we can develop recovery logic in a structured way using functional error handling.

Kotlin isn’t a purely functional language, but Arrow provides good support for monads, and we’ve seen how monads keep code clear when composing functions.

However, this approach needs some diligence on the developer’s side. Kotlin doesn’t force the handling of return values. So we may end up hiding exceptional events that are relevant to our frameworks. Still, this extra effort can really solve many of the problems we face in everyday programming activities.

As always, the code samples can be found over on GitHub.