Blog
Company updates
Shifting error handling to the type system with Arrow
We outline how we overcome the challenge of proper error handling in our continuously-growing codebase while keeping our code clean and tidy.

George Popides
Senior Software Engineer

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 reason our systems become more complex is the number of parts involved in performing a given operation. We need to use databases, communicate with internal or external services, perform actions that may result in 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 to error handling by moving away from exceptions, adopting a method used in functional languages, and making errors part of the type system with the use of the Arrow library.
A usual approach to error handling in the world of JVM
Our services are written in Kotlin, which uses many components that originate from Java. One of them is the Exceptions, though Kotlin only has Unchecked exceptions.
The standard way to handle errors in Java is through 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. We will study the reasons below.
Kotlin follows a similar path, with the major difference being that Kotlin distinguishes between nullable and non-nullable values, which is still not good enough for our error handling.
The problem with exceptions
Let's start with analyzing 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.
*/\n @throws CustomException\n /\nfun doA() {\n throw CustomException("")\n}\n
This means that a caller of doA needs to consult the function's documentation and hope that 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() {\n try {\n doA()\n } catch (e: CustomException) {\n\n }\n}\n
Both are problematic, and the problems arise when:
The exceptions mentioned in the documentation are not correct, for example
doAmay throwSomeOtherExceptioninstead ofCustomExceptionNew exceptions are added
When new exceptions are added, we have to modify our code to handle both exceptions:
fun doB() {\n try {\n doA()\n } catch (e: CustomException) {\n\n } catch (e: SomeOtherException) {\n\n }\n}\n
The issue here is that these exceptions apply only to doA. If we add doC and doD, which throw other exceptions, we will need to handle them, which could result in 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? {\n\n}\n
Now the callers of doC can safely access the attributes of Reservation because the type system will force them to handle the absence of values, which means that this:
val reservation = getReservation(1)\nreservation.date\n
will not compile because the type system signals that the value may be null, and we need to handle it with the null-safe operator (?.)
val reservation = getReservation(1)\nreservation?.date\n
This approach is better because we can be explicit and leverage 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 determine that the reservation does not exist and that the retrieval operation was unsuccessful? 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 that 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> {\n data class Error(value: A) : Result\n data class Success(value: B) : Result\n}\n
This allows us to be explicit about what can go wrong during an operation, which technically means we can do the following:
fun getReservation(id: Long): Result<ReservationError, Reservation>\n
The signature above is explicit about its intentions, which are:
By calling this function, you either get a reservation or an error, and you have to handle it.
The aim of this approach is to have you deal with it yourself, preventing the user from extracting the reservation value 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 that support functional patterns have adopted this pattern, Kotlin being one of them.
Kotlin supports the Result pattern for error handling, but it has a minor limitation. It can only use Throwables to indicate a failure, which forbids us from using custom errors. That is one of the reasons that we decided to use Arrow to achieve our goal.
How we use Arrow
Arrow is a toolset that brings common functional patterns to Kotlin, including typed errors.
The main tool we will show and discuss is Either, which is essentially the same as Result but allows any value to be used as an error.
We will demonstrate how we use Either to perform a single operation on our platform.
Let's examine the following scenario:
We would like to fetch the reservations for a given hotel. The hotel's currency may differ from the customer's currency. That's why we need to show the reservation price in both currencies.
First, we define the function that fetches the reservation
fun getReservation(id: Long): Either<ReservationError, Reservation>\n
Then we proceed with the function that returns the amount in a transformed currency
enum class Currency { USD, EUR, PND, ...}\nfun exchangeAmount(amount: BigDecimal, fromCurrency: Currency, to: Currency): Either<CurrencyError, BigDecimal>\n
Here, Either helps indicate that both getReservation and transformCurrency can fail, and we need to take that into account when using them. Now we need to first retrieve the reservation and then convert the price to a specific currency using flatMap. flatMap is one of the functions mentioned previously that operates 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)\n .flatMap { reservation -> exchangeAmount(reservation.price, reservation.currency, Currency.EUR) }\n
If we execute the above block, there are 3 possible outcomes:
getReservationfails, and we get aReservationErrorgetReservationsucceeds, buttransformCurrencyfails, and we get aCurrencyErrorgetReservationandgetCurrencysucceed, 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)\n\n...\n\ngetReservation(1)\n .flatMap { reservation ->\n exchangeAmount(reservation.price, reservation.currency, Currency.EUR)\n .map { transformedAmount -> ReservationWithForeignExchange(reservation, transformedAmount) }\n }\n
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 shown what happens in the case of errors. Here we have 2 scenarios: ReservationError and CurrencyError are subclasses of the same sealed class, so either one failing 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 allows us to transform (lift) errors to other errors. An example would be the following:
getReservation(1)\n .onLeft { logger.error { "Reservation 1 was not found" }\n .mapLeft { CommonDenominatorError } \n .flatMap { reservation ->\n exchangeAmount(reservation.price, reservation.currency, Currency.EUR)\n .onLeft { error -> logger.error { "Could not exchange currency, reason: $error}" } }\n .mapLeft { CommonDenominetorError }\n .map { transformedAmount -> ReservationWithForeignExchange(reservation, transformedAmount) }\n }\n
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 our implementation in 1 step:
validateRequest(request)\n .flatMap { reservationId ->\n getReservation(reservationId)\n .flatMap { reservation ->\n exchangeAmount(reservation.price, reservation.currency, Currency.EUR)\n .map { transformedAmount -> ReservationWithForeignExchange(reservation, transformedAmount) }\n }\n }\n
For some people, the nested map and flatMap approach can look confusing and repellent, and the Arrow team has already addressed this. 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 {\n val reservationId = validateRequest(request).bind() \n val reservation = getReservation(reservationId).bind()\n val transformedAmount = exchangeAmount(reservation.price, reservation.currency, Currency.EUR).bind()\n ReservationWithForeignExchange(reservation, transformedAmount)\n}\n
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 a 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 {\n object ReservationNotFound : ReservationError\n data class DatabaseError(throwable: Throwable) : ReservationError\n object InternalServiceError : ReservationError\n}\n
Which we can use to handle each case differently:
when (error) {\n is ReservationNotFound -> ...\n is DatabaseError -> ...\n is InternalServiceError -> ...\n} \n
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 have you join us.

George Popides
Senior Software Engineer
Share


