Working with typed errors
Typed errors refer to a technique from functional programming in which we make explicit in the signature (or type) the potential errors that may arise during the execution of a piece of code. This is not the case when using exceptions, which any documentation is not taken into account by the compiler, leading to a defensive mode of error handling.
- Functional Error Handling - A Practical Approach by Bas de Groot
- Exception handling in Kotlin with Arrow by Ramandeep Kaur
- Por qué no uso excepciones en mi código by Raúl Raja and Codely
- Typed error handling in Kotlin by Mitchell Yuwono
- Functional Error Handling in Kotlin by Riccardo Cardin (video and text): part 1, part 2, and part 3
Typed errors live in the arrow-core
library, with high-arity versions of the
zipOrAccumulate
function available in arrow-core-high-arity
.
Concepts and types
In the rest of the documentation we often refer to a few concepts related to error handling.
We use the term logical failure to describe a situation not deemed as successful in your domain, but that it's still within the realms of that domain. For example, if you are implementing a repository for users, not finding a user for a certain query is a logical failure.
In contrast to logical failures we have real exceptions, which are problems, usually technical, which are truly exceptional and are thus not part of our domain. For example, if the connection to the database suddenly drops, or network times out, host unavailable, etcetera. Those cases benefit from the resilience mechanisms provided by Arrow.
When talking about error handling, we often distinguish between success or happy path, and failure. The former represents the case in which everything works as intended, whereas the latter represents a problem. Depending on the approach, the signature of the function only signals that a failure is possible, or additionally describes the range of problems that may arise.
There are two main approaches to representing types in the signature of a function. Fortunately, Arrow provides a uniform API to working with all of them, which is described in the rest of this section.
The first approach is using a wrapper type, in which the return type of your function
is nested within a larger type that provides the choice of error.
In that way the error is represented as a value.
For example, the following signature expresses that the outcome of findUser
is
of type User
when successful, or UserNotFound
when a logical failure is raised.
fun findUser(id: UserId): Either<UserNotFound, User>
The Kotlin standard library includes a few wrapper types, but they are all restricted in the information they may include.
Arrow introduces Either
and Ior
, both giving the developer the choice of type of logical failures, and reflecting that choice
as their first type parameter.
Type | Failure | Simultaneous success and failure? | Kotlin stdlib. or Arrow? |
---|---|---|---|
A? | null | No | |
Option<A> | None | No | |
Result<A> | Failure contains a Throwable , inspection possible at runtime | No | |
Either<E, A> | Left contains value of type E | No | |
Ior<E, A> | Left contains value of type E | Yes, using Both |
The second approach is describing errors as part of the computation context of the function.
In that case the ability to finish with logical failures is represented by having Raise<E>
be part of the context or scope of the function. Kotlin offers two choices here: we can use
an extension receiver, and in the future we may use context parameters.
// Raise<UserNotFound> is extension receiver
fun Raise<UserNotFound>.findUser(id: UserId): User
// Raise<UserNotFound> is context parameter
context(_: Raise<UserNotFound>) fun findUser(id: UserId): User
Let's define a simple program that raises a logical failure of UserNotFound
or returns a User
. We can represent this both as a value Either<UserNotFound, User>
, and as a computation (using Raise<UserNotFound>
).
In the examples in this document we use Either<E, A>
as wrapper type and
Raise<E>
as extension receiver, with the intention of the reader choosing
their preferred type. Note that the same ideas and techniques apply to the
rest of choices outlined above.
Defining the success / happy path
The code below shows how we define a function which successfully returns a User
.
object UserNotFound
data class User(val id: Long)
val user: Either<UserNotFound, User> = User(1).right()
fun Raise<UserNotFound>.user(): User = User(1)
In the case of Either
, since we are creating a value (note the use of val
) we need to additionally wrap our value in the type that represents success.
This type is called Right
, although it's common to use the .right()
extension function to give more predominance to the value itself.
In the case of a computation (note the use of fun
) with Raise
the value is returned directly.
Raising an error
To create a value of a logical failure, we use the left
smart-constructor for Either
, or raise
DSL function for a logical failure inside a Raise
computation.
val error: Either<UserNotFound, User> = UserNotFound.left()
fun Raise<UserNotFound>.error(): User = raise(UserNotFound)
Besides raise
or left
, several DSLs are also available to check invariants.
either { }
and Raise
offer ensure
and ensureNotNull
, in spirit with require
and requireNotNull
from the Kotlin Std.
Instead of throwing an exception, they result in a logical failure with the given error if the predicate is not satisfied.
ensure
takes a predicate and a lazy UserNotFound
value. When the predicate is not matched, the computation will result in a logical failure of UserNotFound
.
In the function below, we show how we can use ensure
to check if a given User
has a valid id, and if not, we return a logical failure of UserNotFound
.
data class UserNotFound(val message: String)
fun User.isValid(): Either<UserNotFound, Unit> = either {
ensure(id > 0) { UserNotFound("User without a valid id: $id") }
}
fun Raise<UserNotFound>.isValid(user: User): User {
ensure(user.id > 0) { UserNotFound("User without a valid id: ${user.id}") }
return user
}
fun example() {
User(-1).isValid() shouldBe UserNotFound("User without a valid id: -1").left()
fold(
{ isValid(User(1)) },
{ _: UserNotFound -> fail("No logical failure occurred!") },
{ user: User -> user.id shouldBe 1 }
)
}
Without context receivers, these functions look pretty different depending on if we use Raise
or Either
. This is because we sacrifice our extension receiver for Raise
.
And thus, the Raise
based computation cannot be an extension function on User
.
In the future, context parameters should allow us to define the function as follows:
context(_: Raise<UserNotFound>)
fun User.isValid(): Unit =
ensure(id > 0) { UserNotFound("User without a valid id: $id") }
ensureNotNull
takes a nullable value and a lazy UserNotFound
value. When the value is null, the computation will result in a logical failure of UserNotFound
.
Otherwise, the value will be smart-casted to non-null, and you can operate on it without checking nullability.
In the function below, we show how we can use ensureNotNull
to check if a given User
is non-null, and if not, we return a logical failure of UserNotFound
.
fun process(user: User?): Either<UserNotFound, Long> = either {
ensureNotNull(user) { UserNotFound("Cannot process null user") }
user.id // smart-casted to non-null
}
fun Raise<UserNotFound>.process(user: User?): Long {
ensureNotNull(user) { UserNotFound("Cannot process null user") }
return user.id // smart-casted to non-null
}
fun example() {
process(null) shouldBe UserNotFound("Cannot process null user").left()
fold(
{ process(User(1)) },
{ _: UserNotFound -> fail("No logical failure occurred!") },
{ i: Long -> i shouldBe 1L }
)
}
Running and inspecting results
We inspect the value of res
using Kotlin's when
, or fold
the computation providing a lambda for both the logical failure and the success case.
fun example() {
when (error) {
is Left -> error.value shouldBe UserNotFound
is Right -> fail("A logical failure occurred!")
}
fold(
block = { error() },
recover = { e: UserNotFound -> e shouldBe UserNotFound },
transform = { _: User -> fail("A logical failure occurred!") }
)
}
Unless you explicitly wrap your code to catch exceptions as part of Either
or Raise
, exceptions bubble up in the usual way. If you need to handle those exceptions, fold
is also available with a catch
argument to recover from any Throwable
that might've been thrown. More information can be found below.
Another possibility is to have a Raise
computation, that we would like to turn into a wrapper type.
In that case we don't have to call fold
, we can use one of the runners, each of them named as the wrapper type, but with all letters in lowercase.
fun example() {
either { error() } shouldBe UserNotFound.left()
}
The converse direction, turning a value of a type like Either
into a
computation with Raise
, is achieved via the .bind()
extension function.
fun Raise<UserNotFound>.res(): User = user.bind()
In fact, to define a result with a wrapper type, we recommend to use one
of the runners (either
, ior
, et cetera), and use .bind()
to "inject"
any sub-computation that may be required, or raise
to describe a logical
failure. We often refer to this approach as using the Raise
DSL.
val maybeTwo: Either<Problem, Int> = either { 2 }
val maybeFive: Either<Problem, Int> = either { raise(Problem) }
val maybeSeven: Either<Problem, Int> = either {
maybeTwo.bind() + maybeFive.bind()
}
The Arrow Detekt Rules project has a set of rules to detekt you call bind
on every Either
value.
Sometimes you may need to have one error type inside another one,
like Either<Problem, Int?>
. The rule of thumb in that case is to nest the
runner functions (either
, option
, nullable
) in the same order as they
appear in the type. When you call raise
, the type of the error given as
argument is used to "select" the appropriate type to fall back to.
fun problematic(n: Int): Either<Problem, Int?> =
either {
nullable {
when {
n < 0 -> raise(Problem)
n == 0 -> raise(null)
else -> n
}
}
}
As projects grow in size, raised errors propagate through the call stack. To make debugging easier, Arrow provides a way to trace calls to raise
and bind
: see Raise.traced.
Recovering from typed errors
We've already hinted this distinction above, but with working with type errors it's important to distinguish between two kinds of problems that may arise:
- Logical failures indicate problems within the domain, and which should be handled as part of the usual domain logic. For example, trying to find a user which doesn't exist, or validating input data.
- Exceptions indicate problems which affect the system's ability to continue working. For example, if the database connection breaks this is something outside your domain logic.
Historically exceptions have been used for both cases. For example, throwing a UserNotValidException
when the input data was wrong.
We advocate for making this distinction clear in the types, and leave exceptions only for exceptional cases.
However, we're also aware of the historical baggage, so we provide tools to transforms those exceptions which shouldn't have been exceptions into typed errors.
From logical failures
When working with values or functions that can result in a typed error, we often need to recover to provide or calculate fallback values.
To demonstrate how we can recover from logical failures, let's define a simple function that returns our User
in case the id > 0
; otherwise it returns UserNotFound
.
suspend fun fetchUser(id: Long): Either<UserNotFound, User> = either {
ensure(id > 0) { UserNotFound("Invalid id: $id") }
User(id)
}
suspend fun Raise<UserNotFound>.fetchUser(id: Long): User {
ensure(id > 0) { UserNotFound("Invalid id: $id") }
return User(id)
}
To recover from any errors on a Either
value, we can most conveniently use getOrElse
, since it allows us to unwrap the Either
and provide a fallback value.
The same can be done for the Raise
based computation using the recover
DSL instead.
suspend fun example() {
fetchUser(-1)
.getOrElse { e: UserNotFound -> null } shouldBe null
recover({
fetchUser(1)
}) { e: UserNotFound -> null } shouldBe User(1)
}
Default to null
is typically not desired since we've effectively swallowed our logical failure and ignored our error. If that was desirable, we could've used nullable types initially.
When encountering a logical failure and not being able to provide a proper fallback value, we typically want to execute another operation that might fail with OtherError
.
As a result, our Either
value doesn't get unwrapped as it did with getOrElse
, since a different logical failure might've occurred.
object OtherError
fun example() {
val either: Either<OtherError, User> =
fetchUser(1)
.recover { _: UserNotFound -> raise(OtherError) }
either shouldBe User(1).right()
fetchUser(-1)
.recover { _: UserNotFound -> raise(OtherError) } shouldBe OtherError.left()
}
The type system now tracks that a new error of OtherError
might have occurred, but we recovered from any possible errors of UserNotFound
. This is useful across application layers or in the service layer, where we might want to recover
from a DatabaseError
with a NetworkError
when we want to load data from the network when a database operation failed.
To achieve the same with the Raise
DSL, we need to be inside the context of Raise<OtherError>
to raise
it.
suspend fun Raise<OtherError>.recovery(): User =
recover({
fetchUser(-1)
}) { _: UserNotFound -> raise(OtherError) }
Since recovery for both Either
and Raise
is DSL based, you can also call bind
or raise
from both.
This allows seamless interop between both types when creating programs that can fail and recovering from them.
From exceptions
When building applications, we often need to wrap side effects or foreign code, like when interacting with the network or databases. Wrapping such APIs requires handling the possibility of failure, and we can do so by returning a logical failure. The question is often, do we need to take into all exceptions or just a subset of them? The answer is that it depends on the use case, but, in general, we should try to be as specific as possible and only handle the exceptions that we can recover from or expect. However, you might want to be more defensive when interacting with improperly defined systems.
Let's look at an example where we interact with a database and want to insert a new user. If the user already exists, we want to return a logical failure of UserAlreadyExists
. Otherwise, we want to return the newly created user.
We again showcase both the code for Either
and Raise
based computation and see that both are almost the same.
The catch
DSL allows us to wrap foreign functions and capture any Throwable
or T: Throwable
that might be thrown. It automatically avoids capturing fatal exceptions such as OutOfMemoryError
, or Kotlin's CancellationException
.
It requires two functions, or lambdas, as arguments: One for wrapping our foreign code and another for resolving the captured Throwable
or T : Throwable
. In this case, instead of providing a fallback value, we raise
a logical failure.
We expect SQLException
since we only expect it to be thrown and rethrow any other Throwable
.
We can then operate on the captured SQLException
to check if our insertion failed with a unique violation, and, in that case, we turn it into a UserAlreadyExists
logical failure.
data class UserAlreadyExists(val username: String, val email: String)
suspend fun Raise<UserAlreadyExists>.insertUser(username: String, email: String): Long =
catch({
UsersQueries.insert(username, email)
}) { e: SQLException ->
if (e.isUniqueViolation()) raise(UserAlreadyExists(username, email))
else throw e
}
Since we also have raise
available inside either
, we can also write the same code using either
or execute this function inside an either
block as shown above.
This behavior is also available as top-level functionality on Either
itself if you prefer to use that. It can be achieved using catchOrThrow
instead of catch
and mapLeft
to transform SQLException
into UserAlreadyExists
.
suspend fun insertUser(username: String, email: String): Either<UserAlreadyExists, Long> =
Either.catchOrThrow<SQLException, Long> {
UsersQueries.insert(username, email)
}.mapLeft { e ->
if (e.isUniqueViolation()) UserAlreadyExists(username, email)
else throw e
}
This pattern allows us to turn exceptions we want to track into typed errors, and things that are truly exceptional remain exceptional.
Accumulating errors
All the behavior above works similarly to Throwable
, but in a typed manner. This means that if we encounter a typed error or logical failure, that error is propagated, and we can't continue with the computation and short-circuit.
When we need to work with collections, or Iterable
, we often want to accumulate all the errors and not short-circuit. Let's take a look at how we can do this.
data class NotEven(val i: Int)
fun Raise<NotEven>.isEven(i: Int): Int =
i.also { ensure(i % 2 == 0) { NotEven(i) } }
fun isEven2(i: Int): Either<NotEven, Int> =
either { isEven(i) }
First, we define two functions that return a typed error if the value is not even.
If we want to accumulate all the errors, we can use mapOrAccumulate
on Iterable
to get all the errors, and doing so for (0..10)
should return the following errors
.
Since you have potentially more than one failure, the error type in Either
must be some sort of list.
However, we know that if we are not in the happy path, then at least one error must have occurred.
Arrow makes this fact explicit by making the return type of mapOrAccumulate
a NonEmptyList
, or Nel
for short.
val errors = nonEmptyListOf(NotEven(1), NotEven(3), NotEven(5), NotEven(7), NotEven(9)).left()
fun example() {
(1..10).mapOrAccumulate { isEven(it) } shouldBe errors
(1..10).mapOrAccumulate { isEven2(it).bind() } shouldBe errors
}
We can also provide custom logic to accumulate the errors, typically when we have custom types.
Below, instead of NonEmptyList<NotEven>
, we have a MyError
type that builds a String with all the error messages.
So we again define two functions that return a typed error if the value is not even.
data class MyError(val message: String)
fun Raise<MyError>.isEven(i: Int): Int =
ensureNotNull(i.takeIf { i % 2 == 0 }) { MyError("$i is not even") }
fun isEven2(i: Int): Either<MyError, Int> =
either { isEven(i) }
And we write a small function that combines two values of our typed error into one, appending the error messages.
operator fun MyError.plus(second: MyError): MyError =
MyError(message + ", ${second.message}")
We can then simply pass this function to the mapOrAccumulate
function, and it will accumulate all the errors into a single MyError
value using our provided function.
val error = MyError("1 is not even, 3 is not even, 5 is not even, 7 is not even, 9 is not even").left()
fun example() {
(1..10).mapOrAccumulate(MyError::plus) { isEven(it) } shouldBe error
(1..10).mapOrAccumulate(MyError::plus) { isEven2(it).bind() } shouldBe error
}
If you need to execute a computation that may raise
errors over all the elements of an iterable or sequence, but without storing the resulting values, forEachAccumulating
is your tool of choice. The relation between mapOrAccumulate
and forEachAccumulating
is similar to that of map
and forEach
in Kotlin's standard library.
fun example() = either {
forEachAccumulating(1 .. 10) { i ->
ensure(i % 2 == 0) { "$i is not even" }
}
}
Accumulating different computations
In the example above we are providing one single function to operate on a sequence of elements.
Another important and related scenario is accumulating different errors, but each of them coming from different computations.
For example, you need to perform validation over the different fields of a form, and accumulate the errors, but each field has different constraints.
Arrow supports two different styles for this task: using zipOrAccumulate
, and using the accumulate
scope.
As a guiding example, let's consider information about a user, where the name shouldn't be empty and the age should be non-negative.
data class User(val name: String, val age: Int)
It's customary to define the different problems that may arise from validation as a sealed interface:
sealed interface UserProblem {
object EmptyName: UserProblem
data class NegativeAge(val age: Int): UserProblem
}
Let's define validation as a smart constructor, that is, by creating a function which looks like the User
constructor, but performs additional checks.
data class User private constructor(val name: String, val age: Int) {
companion object {
operator fun invoke(name: String, age: Int): Either<UserProblem, User> = either {
ensure(name.isNotEmpty()) { UserProblem.EmptyName }
ensure(age >= 0) { UserProblem.NegativeAge(age) }
User(name, age)
}
}
}
Alas, that implementation stops after the first error. We can see this if we try to validate a User
with both an empty name and a wrong age.
fun example() {
User("", -1) shouldBe Left(UserProblem.EmptyName)
}
If you want to gather as many validation problems as possible, you need to switch to accumulation, as done above with mapOrAccumulate
.
Bu in this case we want to run independent validations of a different type, each for each field comprising the value.
The first approach is to use zipOrAccumulate
.
In that case the first arguments define the different independent validations, often as a block of code.
If all those validations succeed, that is, when no problem was raise
d during execution of any of them,
then the final block is executed. The result of the independent validations are made available, in case they are required.
data class User private constructor(val name: String, val age: Int) {
companion object {
operator fun invoke(name: String, age: Int): Either<NonEmptyList<UserProblem>, User> = either {
zipOrAccumulate(
{ ensure(name.isNotEmpty()) { UserProblem.EmptyName } },
{ ensure(age >= 0) { UserProblem.NegativeAge(age) } }
) { _, _ -> User(name, age) }
}
}
}
With this change, the problems are correctly accumulated. Now we can present the user all the problems in the form at once.
fun sample() {
User("", -1) shouldBe Left(nonEmptyListOf(UserProblem.EmptyName, UserProblem.NegativeAge(-1)))
}
The second approach involves delimiting a scope where accumulation should take place using accumulate
. That way we bring into scope variations of most functions described above, like ensureOrAccumulate
and bindOrAccumulate
. One important difference, though, is that when the computation returns a value, you must use by
(property delegation) instead of =
to obtain the value.
accumulate {
val thing by checkThing().bindOrAccumulate()
}
Translating the example above to this new style leads to the following code. We introduce no delegation because ensureOrAccumulate
returns no interesting value.
data class User private constructor(val name: String, val age: Int) {
companion object {
operator fun invoke(name: String, age: Int): Either<NonEmptyList<UserProblem>, User> = either {
accumulate {
ensureOrAccumulate(name.isNotEmpty()) { UserProblem.EmptyName }
ensureOrAccumulate(age >= 0) { UserProblem.NegativeAge(age) }
User(name, age)
}
}
}
}
The behavior is exactly the same as with zipOrAccumulate
, that is, all potential errors are accumulated.
fun example() {
User("", -1) shouldBe Left(nonEmptyListOf(UserProblem.EmptyName, UserProblem.NegativeAge(-1)))
}
In addition to accumulating errors, you may want to perform each of the tasks within zipOrAccumulate
or mapOrAccumulate
in parallel.
Arrow Fx features parZipOrAccumulate
and parMapOrAccumulate
to cover
those cases, in addition to parZip
and parMap
which follow a short-circuiting approach.
Transforming errors
We call this approach typed errors because at every point the signatures state which is the type of errors that may be raised from some computation. This type is checked when .bind
ing, which means that you cannot directly consume computation with a given error type within a block with a different one. The solution is to transform the error, which is achieved using withError
.
val stringError: Either<String, Boolean> = "problem".left()
val intError: Either<Int, Boolean> = either {
// transform error String -> Int
withError({ it.length }) {
stringError.bind()
}
}
A very common pattern is using withError
to "bridge" validation errors of sub-components into validation errors of the larger value.
In the context of nullable and Option
you often need to "forget" the error type if consuming more informative types like Either
. Although you can achieve this behavior using withError
, we provide a more declarative version called ignoreErrors
.
Summary
At this point we can summarize the advantages that typed errors offer over using exceptions:
-
Type Safety: Typed errors allow the compiler to find type mismatches early, making it easier to catch bugs before they make it to production. However, with exceptions, the type information is lost, making it more difficult to detect errors at compile-time.
-
Predictability: When using typed errors, the possible error conditions are explicitly listed in the type signature of a function. This makes it easier to understand the possible error conditions and write tests covering all error scenarios.
-
Composability: Typed errors can be easily combined and propagated through a series of function calls, making writing modular, composable code easier. With exceptions, ensuring errors are correctly propagated through a complex codebase can be difficult. Patterns like accumulation, which are at your fingertips using typed errors, become quite convoluted using exceptions.
-
Performance: Exception handling can significantly impact performance, especially in languages that don't have a dedicated stack for exceptions. Typed errors can be handled more efficiently as the compiler has more information about the possible error conditions.
In summary, typed errors provide a more structured, predictable, and efficient way of handling errors and make writing high-quality, maintainable code easier.
We can use the Either
type to represent a value that can either be a success or a failure, and we can use the Raise
DSL to raise typed errors without wrappers.
Since all these functions and builders are built on top of Raise
, they all seamlessly work together, and we can mix and match them as we please.
If you have any questions or feedback, please reach out to us on Slack or Github.