Shifting error handling to the type system with Arrow

Shifting error handling to the type system with Arrow

In this post we will outline how we overcome the challenge of proper error handling in our continuously-growing codebase while keeping our code clean and tidy.

One of the reasons that our systems become more complex is the number of parts that are involved to perform a certain operations. We need to use databases, communicate with internal or external services, perform actions that may result to errors because of invalid input and so on. In short, the points of failure increase.

Here we will discuss how our engineering team shifted our approach for error handling by moving away from exceptions and adopting a method that is used in functional languages and making the error part of the type system with the use of the Arrow library.

A usual approach of error handling in the world of JVM

Our services are written in Kotlin which uses a lot of components that originate from Java. One of them is the Exceptions, though Kotlin only has Unchecked exceptions.

The standard way of error handling in Java is Exceptions and null values. Functions that need to indicate that something is wrong either return a null value or throw an Exception, which is problematic in both cases and we will study the reasons below.

Kotlin follows a similar path with the major difference being that Kotlin distinguishes nullable and non-nullable values, which still is not good enough for our error handling.

The problem with exceptions

Lets start with analysing why exceptions are not the most appropriate method to handle errors in Kotlin (most of the reasons apply for Java too).

The first reason is that Kotlin only has Unchecked exceptions. This means that the only way for a function to indicate that it may throw an exception is by mentioning it in the function’s documentation.

*/
  @throws CustomException
 /
fun doA() {
    throw CustomException("")
}

This means that a caller of doA needs to look at the documentation of the function and hope that the whoever implemented doA will document the exceptions that can be thrown, or look at the source code. Furthermore the caller of doA will look like this:

fun doB() {
    try {
        doA()
    } catch (e: CustomException) {

    }
}

Both are problematic, and the problems arise when:

  • The exceptions mentioned in the documentation are not correct, for example doA may throw SomeOtherException instead of CustomException
  • New exceptions are added

When new exceptions are added, we have to modify our code to handle both exceptions:

fun doB() {
    try {
        doA()
    } catch (e: CustomException) {

    } catch (e: SomeOtherException) {

    }
}

The issue here is that these exceptions are only applicable for doA, if we add doC and doD that throw other exceptions, we will need to handle them which could result into a very large function where most of the code will be catch (e: Exception)

The problem with nullable values

Kotlin's type system has nullable values which can be used for error handling. Nullable values allow us to explicitly indicate that a function can return a value that may be null.

fun getReservation(id: Long) : Reservation? {

}

Now the callers of doC can safely access attributes of Reservation because the type system will force them to handle the absence of values which means that this:

val reservation = getReservation(1)
reservation.date

will not compile, because the type system is signalling that the value may be null and we need to handle it using the null safe operator (?.)

val reservation = getReservation(1)
reservation?.date

This approach is better because we can be explicit and utilize the support of the type system to enforce handling of null values. But in this case, our problem is that null values are ambiguous. For instance, in the example above, how could we distinguish that the reservation does not exist and that the operation of getting the reservation was not successful? We cannot and this is the problem of nullable values for error handling.

The Result pattern

Functional languages have solved this problem by using the Result pattern. The result pattern is a type which can hold 2 values. 1 to indicate an error and 1 to indicate a success, but not both at the same time.

A simple example of the type would be the following:

sealed interface Result<A, B> {
    data class Error(value: A) : Result
    data class Success(value: B) : Result
}

This allows us to be explicit about what can wrong during an operation, which technically means we can do the following:

fun getReservation(id: Long): Result<ReservationError, Reservation>

The signature above is explicit about its intentions, which is:

By calling this function you either get the reservation or an error and you have to deal with it.

The aim of this approach, the you have to deal with it yourself , is to prevent the user from extracting the value of the reservation without considering the possibility of an error and handling it. We can do that by using specific functions (which we will look at below) that operate on the Result type.

The result pattern is very common in functional languages and the benefits are many and obvious. That's why other languages which support functional patterns have adopted this pattern, Kotlin being one of them.

Kotlin supports the Result pattern which can be used for error handling but it has a minor limitation. It can use only Throwables to indicate a failure, which forbids us from using custom errors to indicate failures. That is one of the reasons that we decided to use Arrow to achieve our goal.

How we use Arrow

Arrow is a toolset which brings common functional patterns to Kotlin, one of them being typed errors.

The main tool we will show and discuss is Either which is essentially the same as Result but allows any values to be used as errors.

We will demonstrate how we are using Either to perform one operation in our platform.

Lets examine the following scenario:

We would like to fetch the reservations for a given hotel. The hotel's currency may be different than the customer's currency. That's why we need to show the price of the reservation in both currencies.

First, we define the function that fetches the reservation

fun getReservation(id: Long): Either<ReservationError, Reservation>

Then we proceed with the function that returns the amount in a transformed currency

enum class Currency { USD, EUR, PND, ...}
fun exchangeAmount(amount: BigDecimal, fromCurrency: Currency, to: Currency): Either<CurrencyError, BigDecimal>

Here, Either helps us indicate the fact that both getReservation and transformCurrency can fail and we need to take that into account while using them. Now we need to first get the reservation and then transform the price to a specific currency by using flatMap. flatMap is one of the functions mentioned previously that operate on Either and its meaning is:

If the value of the returned Either is Right, execute the function passed and return its result (which must also be an Either), otherwise skip the function execution and return the error.

This allows us to structure our code like this:

getReservation(1)
  .flatMap { reservation -> exchangeAmount(reservation.price, reservation.currency, Currency.EUR) }

If we execute the above block, there are 3 possible outcomes:

  • getReservation fails and we get a ReservationError
  • getReservation succeeds but transformCurrency fails and we get a CurrencyError
  • getReservation and getCurrency succeed and we get back the price of the reservation in the desired currency

If our goal is to just transform the currency then we can stop here. But our goal is not that, we want both the reservation and the transformed currency in 1 object.

We can use  a ReservationWithForeignExchange data class and use map after transforming the currency.

data class ReservationWithForeignExchange(val reservation: Reservation, val transformedCurrency: BigDecimal)

...

getReservation(1)
  .flatMap { reservation ->
      exchangeAmount(reservation.price, reservation.currency, Currency.EUR)
        .map { transformedAmount -> ReservationWithForeignExchange(reservation, transformedAmount) }
  }

Finally with just a few lines we have ensured that we can get the result we want without unexpected errors.

Handling errors

So far we haven’t show what happens in the case of errors. Here we have 2 scenarios: ReservationError and CurrencyError are subclasses of the same sealed class which means that no matter which one of those fails the error can be handled by the same handler.

In reality what would be more common is to map the errors to a common denominator class.

Since we also want to have a specific implementation for each error (e.g logging an error indicating the specific details of the error), we can use onLeft and mapLeft

The first method allows us to perform a side effect on the error case and the second to transform (lift) errors to other errors. An example would be the following:

getReservation(1)
  .onLeft { logger.error { "Reservation 1 was not found" }
  .mapLeft { CommonDenominatorError } 
  .flatMap { reservation ->
      exchangeAmount(reservation.price, reservation.currency, Currency.EUR)
        .onLeft { error -> logger.error { "Could not exchange currency, reason: $error}" } }
        .mapLeft { CommonDenominetorError }
        .map { transformedAmount -> ReservationWithForeignExchange(reservation, transformedAmount) }
  }

It’s worth mentioning that there is also an onRight method which is the opposite of onLeft . It can be used for example to log a successful operation for tracing reasons.

Validation

We are using grpc and protobuf to let our internal services communicate with each other. Unfortunately, protobuf needs a custom solution for validating requests and this is where we also use arrow.

By having validation functions for the protobuf objects that return Either, we can compose them with our business logic functions

For example by having the function below:

fun validateRequest(request: GetReservationRequest): Either<ValidationError, Long>

We can compose it with with our implementation in 1 step:

validateRequest(request)
  .flatMap { reservationId ->
     getReservation(reservationId)
       .flatMap { reservation ->
         exchangeAmount(reservation.price, reservation.currency, Currency.EUR)
          .map { transformedAmount -> ReservationWithForeignExchange(reservation, transformedAmount) }
       }
  }

For some people the nested map and flatMap approach looks confusing and repellent and this is something that the Arrow team already has already addressed. With the newer Arrow versions (1.2, 2.x ) we can make use of the new dsl which allows us to rewrite the block above to:

either {
  val reservationId = validateRequest(request).bind() 
  val reservation = getReservation(reservationId).bind()
  val transformedAmount = exchangeAmount(reservation.price, reservation.currency, Currency.EUR).bind()
  ReservationWithForeignExchange(reservation, transformedAmount)
}

Error modelling

Previously, we mentioned that a problem we had with Result was that it only accepts throwables to indicate errors which is not the case for Arrow. This allows for more concrete model for our errors using sealed classes and interfaces, thus being more flexible with our error handling. For example the ReservationError that we used previously, can be the following:

sealed interface ReservationError {
  object ReservationNotFound : ReservationError
  data class DatabaseError(throwable: Throwable) : ReservationError
  object InternalServiceError : ReservationError
}

Which we can use with when to handle each case differently:

when (error) {
  is ReservationNotFound -> ...
  is DatabaseError -> ...
  is InternalServiceError -> ...
} 

Closing thoughts

In this post we used a very small example to demonstrate the core principles of Arrow and functional programming which we use to build our system.

We have small building blocks with explicit return types that indicate failures if there are possibilities for a failure and our job is to compose them.

This core principle allowed us to stop using exceptions, and stop getting unexpected exceptions from libraries that use them. Now all our errors are properly logged and we can locate them quickly enough.

P.S.: Did you enjoy this article and challenges like the above excite you? We have a few open positions in our team and we would love to join us: https://careers.katanox.com/