Skip to main content

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.

Media resources
Where to find it

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.

Logical Failure vs. Real exceptions

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.

Success and failure

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.

TypeFailureSimultaneous
success and failure?
Kotlin stdlib. or Arrow?
A?nullNo
Option<A>NoneNo
Result<A>Failure contains a Throwable,
inspection possible at runtime
No
Either<E, A>Left contains value of type ENo
Ior<E, A>Left contains value of type EYes, 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>).

Two examples per code block

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!") }
)
}
Fold over all possible cases

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()
}
Don't forget your binds!

The Arrow Detekt Rules project has a set of rules to detekt you call bind on every Either value.

Nested error types

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
}
}
}
Tracing the origin of a raise

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) }
DSLs everywhere

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.

Non-empty lists

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
}
Accumulating errors but not values

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 raised 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)))
}
Error accumulation and concurrency

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 .binding, 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.

Ignoring errors

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.