A birds-eye view of Arrow: Error Handling
This is a chapter from the book Functional Kotlin. You can find it on LeanPub or Amazon. It is also available as a course.
This part was written by Simon Vergauwen, with support from Alejandro Serrano Mena and Raúl Raja Martínez.
When writing code in a functional style, we typically want our function signatures to be as accurate as possible. We don't wish to have errors represented as exceptions; instead, we reflect these errors as part of the return type of a function. Composing functions that return types with errors is more complex.
One of the most common exceptions in Java is NullPointerException
, and Kotlin has an elegant solution: nullable types! Kotlin allows us to model the absence of a value through nullable types.
For example, Java offers Integer.parseInt
, which can unexpectedly throw NumberFormatException
. Still, Kotlin has String.toIntOrNull
, which returns Int?
as a result type and produces null
when a String
can't coerce to an Int.
Kotlin doesn't have checked exceptions, so there is no way for a function to signal to the caller that it needs to be wrapped in try-catch. When using nullable types, we can force the user to handle possible null values or failures.
Working with nullable types
Let's take a simple example that reads a value from the environment and results in an optional String
value. In the function below, the exception from System.getenv
is swallowed and flattened into null
.
Now we can use this function to read values from our environment and build a simple example of combining nullable functions to load a data class Config(val port: Int)
. Within Java, the most common way to deal with null
is to use if(x != null)
, so let's explore that first.
The simple example is already considerably complex and contains some repetition. Luckily, the Kotlin compiler has smart-casted the values to non-null inside the branch of each if
statement, thus ensuring you can safely access them as non-nullable values.
Kotlin offers much nicer ways of working with nullable types, such as ?.
and scoping functions like let
.
The same code above can be expressed as:
The above snippet is more Kotlin idiomatic and easier to read. Sadly, this syntax only works for nullable types; other types such as Result
or Either
cannot benefit from the special ?
syntax.
There are two improvements we could make to the code above:
- Unify APIs to work with errors and nullable types.
- Swallow all exceptions from
System.getenv
intonull
.
To solve the first issue, we can leverage Arrow's DLSs. A DSL is a Domain Specific Language or an API that is specific to working with a particular domain. Arrow offers DSLs based on continuations that offer a unified API for working with all error types.
First, rewrite our above example using the nullable
Arrow DSL. The nullable.eager
offers us a DSL with bind
, which allows us to unwrap Int?
to Int
.
In Arrow 1.x.x there is
nullable.eager { }
for non-suspend code, andnullable { }
for suspend code. In Arrow 2.x.x this will become simplynullable { }
for both suspend & non-suspend code.
If we encounter a null
value when unwrapping Int?
to Int
using bind
, then the nullable.eager { }
DSL will immediately return null
without running the rest of the code in the lambda. Using .bind
is an easier alternative to applying the Elvis
operator on each check and short-circuiting the lambda with an early return:
To prevent swallowing any exceptions from System.getenv
, we can use runCatching
and Result
from the Kotlin Standard Library.
Working with Result
Result<A>
is a special type in Kotlin that we can use to model the result of an operation that may succeed or may result in an exception. To more accurately model our previous operation envOrNull
of reading a value from the environment, we use Result
to model the failure of System.getenv
. Additionally, the environment variable might not be present, so the function should return Result<String?>
to also model the potential absence of the environment variable.
Our previous envOrNull
can leverage Result
as the return type:
The envOrNull
function defined above now correctly models the failure of System.getenv
and the potential absence of our environment variable. Now, we need to deal with nullable types
inside the context of Result
. Luckily, the Arrow DSL offers a DSL for Result
that allows us to work with Result
in the same way as we did for the nullable types
above.
To ensure that our environment variable is present, the Arrow DSL offers ensureNotNull
, which checks if the passed value envOrNull
is not null
and smart-casts it. If ensureNotNull
encounters a null
value, it returns a Result.failure
with the passed exception. In this case, we return Result.failure(IllegalArgumentException("Required port value was null."))
when encountering null
.
Finally, we must transform our String
into an Int
. The most convenient way of doing this inside the Result
context is using toInt
, which throws a NumberFormatException
if the passed value is not a valid Int
. When using toInt
, we can use runCatching
to safely turn it into Result<Int>
.
The example above used Kotlin's Result
type to model the different failures to load the configuration:
- Any exceptions thrown from
System.getenv
usingSecurityException
orThrowable
- The absence of the environment variable using
IllegalStateException
- The failure of
toInt
usingNumberFormatException
If the API you are interfacing with throws exceptions, Result
might be the best way to model your use case. If you are designing a library or application, you may want to control your error types, and these types do not need to be part of the Throwable
or exception hierarchies.
It doesn't make sense to use Result
for every error type you want to model. With Either
, we can model the different failures to load the configuration in a more expressive and better-typed way without depending on exceptions or Result
.
Working with Either
Before we dive into solving our problem with Either
, let's first take a quick look at the Either
type itself.
Either<E, A>
models the result of a computation that might fail with an error of type E
or success of type A
. It's a sealed class
, and the Left
and Right
subtypes accordingly represent the Error and Success cases.
When modeling our errors with Either
, we can use any type to represent failures arising from loading our Config
.
In our Result
example, we used the following exceptions to model our errors:
SecurityException
/Throwable
when accessing the environment variableIllegalStateException
when the environment variable is not presentNumberFormatException
when the environment variable is present but is not a validInt
In this new example based on Either
, we can instead model our errors with a sealed type ConfigError
.
ConfigError
is a sealed interface
that represents all the different kinds of errors that can occur when loading. During the loading of our configuration, an unexpected system error could occur, such as java.lang.SecurityException
. The SystemError
type represents this. When the environment variable is absent, we should return the PortNotAvailable
type; when the environment variable is present but is not a valid Int
, we should return an InvalidPort
type.
This new error encoding based on a sealed hierarchy changes our previous example to:
The above example uses Either.catch
to catch any exception thrown by System.getenv
; it then _map_s them to a SecurityError
using mapLeft
before calling bind
. If we had not mapped our error from Either<Throwable, String?>
to Either<SecurityError, String?>
, we would not have been able to call bind
because our Either<ConfigError, Config>
context can only handle errors of type ConfigError
. Finally, we use ensureNotNull
again to check if the environment variable is present. We also rely on ensureNotNull
for the result of the toIntOrNull
call.
Our original sample has improved so as to not swallow any exceptions and return all errors in a typed manner.
A final improvement we can still make to the function that loads our configuration is to ensure that the Port
is valid. So, we check if the value lies between 0
and 65535
; if not, we return our existing error type InvalidPort
.
In the examples above, we've learned that we can have all flavors of error handling with nullable types, Result or Either. We use nullable types when a value can be absent, or we don't have any useful error information; we use Result when the operations may fail with an exception; and we use Either
when we want to control custom error types that are not exceptions.