Scala 3: Error handling in FP land
Table of contents
Introduction
Scala 3 introduces union types. Straight from the official documentation, a union type A | B
has as values all values of type A
and also all values of type B
.
So the following code snippet compiles performing an exhaustive pattern-matching.
def foo(x: Int | Long): Unit =
x match
case _: Int => println("Int!!!")
case _: Long => println("Long!!!")
However, you are not here for boring examples, are you? :)
Error types
Union types are the perfect feature to model error types. In Scala 2, we could represent the presence of errors via the Either
monad. E.g.
type Err = Either[UserNotFound, Unit]
However, if we want to model other errors, we can either get into Shapeless’ Coproducts (fairly common during the Free Monad hype-era!) or into nested Either
s (arghhh). E.g.
type Err = Either[Either[DuplicateStory, UserNotFound], Unit]
Though, as we usually work in some F[_]
context, you can imagine things get only much more complicated and less ergonomic from here.
Union types to the rescue!
With union types, we can keep a single Either
instead.
type Err = Either[DuplicateStory | UserNotFound, Unit]
Or we could eliminate the Either
type altogether!
type Err = DuplicateStory | UserNotFound | Unit
Much nicer! Now, how do we get this to play nicely in the F
context? Bear with me a little longer.
Error handling
In my experience, error types are only desirable at the bottom layers. Most of the error handling should occur at the mid layers (business logic) where the errors are eliminated, or perhaps a few errors should be left for the top layers to handle.
To put these words into an example, let’s say we work with a three-layer application.
Bottom layer
At the bottom level, we have an UserStore
that interacts with a database.
trait UserStore[F[_]]:
def fetch(id: UserId): F[Option[User]]
def save(user: User): F[Either[DuplicateEmail | DuplicateUsername, Unit]]
Where the error types are subtypes of Throwable
(this will make our lives easier).
import scala.util.control.NoStackTrace
case object DuplicateEmail extends NoStackTrace
type DuplicateEmail = DuplicateEmail.type
case object DuplicateUsername extends NoStackTrace
type DuplicateUsername = DuplicateUsername.type
If you have read my book Practical FP in Scala, you know I am not a big fan of stack traces :)
Middle layer
Now at the middle layer, we might have our business logic, making calls to the UserStore
, and perhaps to other components that interact with the outside world.
In the following mid layer, our aim is to handle all declared errors, so we pattern match on all cases.
def mid1[F[_]: Logger: MonadThrow](
producer: Producer[F, String],
userStore: UserStore[F]
): User => F[Unit] = user =>
userStore.save.flatMap {
case Right(_) =>
producer.send(s"User $user persisted!")
case Left(DuplicateEmail) =>
Logger[F].error(s"Email ${user.email} already taken!")
case Left(DuplicateUsername) =>
Logger[F].error(s"Email ${user.email} already taken!")
}
After flatMap
ping the result of save
, we can pattern-match on the possible values. The nice thing here is that the Scala compiler checks for exhaustivity, so we never miss a declared error, in case that changes in the future.
Sometimes, however, it happens that we are only interested in handling a subset of the errors, and whatever is added later should be handled at the top layers.
Let’s say we only need to handle the DuplicateEmail
error.
def mid2[F[_]: Logger: MonadThrow](
producer: Producer[F, String],
userStore: UserStore[F]
): User => F[Either[DuplicateUsername, Unit]] = user =>
userStore.save.flatMap {
case Right(_) =>
producer.send(s"User $user persisted!").map(_.asRight)
case Left(DuplicateEmail) =>
Logger[F].error(s"Email ${user.email} already taken!").map(_.asRight)
case Left(e) =>
e.asLeft.pure[F]
}
Any other error is caught in the last case, where we simply leave it unhandled.
This works, though, the ergonomics are not the best, as we need to manually call asRight
and asLeft
in different places. We can improve the UX with some custom extension methods.
def mid3[F[_]: Logger: MonadThrow](
producer: Producer[F, String],
userStore: UserStore[F]
): User => F[Either[DuplicateUsername, Unit]] = user =>
userStore
.save
.rethrow
.as(s"User $user persisted!")
.recoverErrorWith {
case DuplicateEmail =>
Logger[F].error(s"Email ${user.email} already taken!")
}
.lift
First of all, rethrow
eliminates the inner Either
, giving us F[Unit]
. Next, we handle the error we are interested in via recoverErrorWith
. Both functions are defined by ApplicativeError
.
Until here the resulting type remains F[Unit]
. At last, the magic happens when we call lift
and get F[Either[DuplicateUsername, Unit]]
back!
So what is lift
? It is a custom extension method defined as follows.
extension [F[_]: MonadThrow, A](fa: F[A])
@nowarn
def lift[E <: Throwable]: F[Either[E, A]] =
fa.map(_.asRight[E]).recover { case e: E => e.asLeft }
UPDATE: Vasil Vasilev discovered the existence of attemptNarrow
from ApplicativeError
, after having a quick chat about the differences between lift
and attempt
, which is exactly what this does.
The only difference, is that attemptNarrow
requires a ClassTag
, but that’s not a problem :)
def lift[E <: Throwable: ClassTag]: F[Either[E, A]] =
fa.attemptNarrow
We could use attemptNarrow
directly, but if you get to the end of this post, you’ll understand why I chose to keep the lift
extension method instead.
Notice that for this to work, we need to either declare the function’s return type or to indicate what types we expect when we call lift
. E.g.
val f: IO[Unit] = IO.raiseError(Err1)
val g: IO[Either[Err1 | Err2, Unit]] = f.lift
val h = f.lift[Err1 | Err2]
g <-> h
f <-> g.rethrow <-> h.rethrow
Here’s another way of doing the same without rethrow
and recoverErrorWith
.
def mid4[F[_]: Logger: MonadThrow](
producer: Producer[F, String],
userStore: UserStore[F]
): User => F[Either[DuplicateUsername, Unit]] = user =>
userStore.save.flatMap {
case Right(_) =>
producer.send(s"User $user persisted!")
case Left(DuplicateEmail) =>
Logger[F].error(s"Email ${user.email} already taken!")
case Left(e) =>
e.raiseError
}.lift
The only problem with the technique used in both mid3
and mid4
, is that we lose the error type information after a partial error handling and lifting. For instance, if we add another error to UserStore[F]#save
, the compiler won’t help us here.
Nevertheless, this is easily fixed by pattern matching on all the errors, but it might require some repetition regarding raiseError
.
def mid5[F[_]: Logger: MonadThrow](
producer: Producer[F, String],
userStore: UserStore[F]
): User => F[Either[DuplicateUsername, Unit]] = user =>
userStore.save.flatMap {
case Right(_) =>
producer.send(s"User $user persisted!")
case Left(DuplicateEmail) =>
Logger[F].error(s"Email ${user.email} already taken!")
case Left(DuplicateUsername) =>
e.raiseError
}.lift
If we add another FooError
type in the bottom layers, the compiler is going to catch it here for us, and all we need to go is to re-raise it.
case Left(FooError) =>
e.raiseError
That’s what I mean with the potential repetition regarding raiseError
.
To conclude with this mid-layer section, let’s just say that all of these error handling techniques are valid; only they have different trade-offs.
Top layer
At the top layer, is where we either handle the error or we let it crash. So here’s the perfect place to use rethrow
or raiseError
s we don’t care about.
In the following example, we do not care about any unhandled errors so we let it fail.
def top1(
consumer: Consumer[IO, User],
mid: User => IO[Either[DuplicateUsername, Unit]]
): IO[Unit] =
consumer.receive.evalMap { user =>
mid(user).rethrow *> consumer.ack
}
If this is called by a library like Http4s, this will be translated into an HTTP response with code 500, for example.
Or we could do something about it.
def top2(
consumer: Consumer[IO, User],
mid: User => IO[Either[DuplicateUsername, Unit]]
): IO[Unit] =
consumer.receive.evalMap { user =>
mid(user).flatMap {
case Right(_) => consumer.ack
case Left(DuplicateUsername) =>
logger.warn("Duplicate username") *> consumer.ack
}.handlerErroWith { e =>
logger.error(s"Unhandled $e, let it crash?") *> consumer.nack
}
}
Any unhandled errors will be logged and unacked (negative acknowledge).
Furthermore
At the beginning of the post, when the idea of using union types for error modelling was introduced, it was hinted that we could eliminate the Either
altogether. How about that?
Starting from the bottom layer, we can do this instead.
trait UserStore[F[_]]:
def fetch(id: UserId): F[Option[User]]
def save(user: User): F[DuplicateEmail | DuplicateUsername | Unit]
However, by doing so, we lose the rethrow
ability, as we are no longer working with Either
.
Challenge accepted! Let’s introduce a rethrowU
that works on union types.
extension [F[_]: MonadThrow, E <: Throwable, A](fa: F[E | A])
def rethrowU: F[A] =
fa.map(_.asEither).rethrow
extension [E <: Throwable, A](ut: E | A)
@nowarn
def asEither: Either[E, A] =
ut match
case e: E => Left(e)
case a: A => Right(a)
In the same way, we can also introduce a liftU
, defined in terms of lift
under the same scope.
extension [F[_]: MonadThrow, A](fa: F[A])
def liftU[E <: Throwable: ClassTag]: F[E | A] =
lift.map(_.asUnionType)
extension [E, A](either: Either[E, A])
def asUnionType: E | A =
either match
case Left(e: E) => e
case Right(a: A) => a
This is the reason why I kept the lift
extension method instead of using attemptNarrow
. However, I could have also named this attemptNarrowU
instead of liftU
, but I prefer the shorter names :)
Now we can rewrite the final mid5
as follows.
def mid6[F[_]: Logger: MonadThrow](
producer: Producer[F, String],
userStore: UserStore[F]
): User => F[DuplicateUsername | Unit] = user =>
userStore.save.flatMap {
case () =>
producer.send(s"User $user persisted!")
case DuplicateEmail =>
Logger[F].error(s"Email ${user.email} already taken!")
case DuplicateUsername =>
e.raiseError
}.liftU
And the final top2
as shown below.
def top3(
consumer: Consumer[IO, User],
mid: User => IO[DuplicateUsername | Unit]
): IO[Unit] =
consumer.receive.evalMap { user =>
mid(user).flatMap {
case () => consumer.ack
case DuplicateUsername =>
logger.warn("Duplicate username") *> consumer.ack
}.handlerErroWith { e =>
logger.error(s"Unhandled $e, let it crash?") *> consumer.nack
}
}
Conclusion
I think this error modeling and handling technique is very promising. I would probably still keep the Either[E1 | E2, A
] model over E1 | E2 | A
, but this blog post has demonstrated that both options are valid.
The code shown in this post hasn’t been compiled, but I use the very same technique in the project of my upcoming book, so you can have a look the source code directly.
Let’s also remind ourselves that the left side of Either
representing errors is merely a social agreement. We could as well do it the other way around, but that would need different Functor
/ Monad
instances, so it is not quite practical.
In the same way, we could agree that only the right hand-side type of a union type represents the successful value, and any other types on the left hand-side represent the errors. We could probably write typeclass instances that prove the lawfulness of such approach.
Error handling is always a hot topic in FP land, so don’t take this as the ultimate word, but simply as a technique that can be exploited for our benefits :)
Cheers, Gabriel.