Shifting error handling to the type system with Arrow
In this post i will outline how we overcome at the place where i work 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 i 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
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 (?.)
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 {
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
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
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
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.
- 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
We can use a ReservationWithForeignExchange data class and use map after transforming the currency.
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) }
}
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
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) }
}
}
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.