In day-to-day programming, it is fairly common to find ourselves writing functions that can fail. For instance, querying a service may result in a connection issue, or some unexpected JSON response.
To communicate these errors it has become common practice to throw exceptions. However, exceptions are not tracked in any way, shape, or form by the Scala compiler. To see what kind of exceptions (if any) a function may throw, we have to dig through the source code. Then to handle these exceptions, we have to make sure we catch them at the call site. This all becomes even more unwieldy when we try to compose exception-throwing procedures.
val throwsSomeStuff: Int => Double = ???
val throwsOtherThings: Double => String = ???
val moreThrowing: String => List[Char] = ???
val magic = throwsSomeStuff.andThen(throwsOtherThings).andThen(moreThrowing)
Assume we happily throw exceptions in our code. Looking at the types, any of those functions
can throw any number of exceptions, we don't know. When we compose, exceptions from any of
the constituent functions can be thrown. Moreover, they may throw the same kind of exception
(e.g. IllegalArgumentException
) and thus it gets tricky tracking exactly where that
exception came from.
How then do we communicate an error? By making it explicit in the data type we return.
Either
vs Validated
In general, Validated
is used to accumulate errors, while Either
is used to short-circuit a computation upon the
first error. For more information, see the
[Validated
vs Either
](https://typelevel.org/cats/datatypes/validated.html#validated-vs-either)
section of the Validated
documentation.
More often than not we want to just bias towards one side and call it a day - by convention, the right side is most often chosen.
val right: Either[String, Int] = Either.right(5)
right.map(_ + 1) should be(res0)
val left: Either[String, Int] = Either.left("Something went wrong")
left.map(_ + 1) should be(res1)
Because Either
is right-biased, it is possible to define a Monad
instance for it.
Since we only ever want the computation to continue in the case of Right
(as captured
by the right-bias nature), we fix the left type parameter and leave the right one free.
import cats.implicits._
import cats.Monad
implicit def eitherMonad[Err]: Monad[Either[Err, *]] =
new Monad[Either[Err, *]] {
def flatMap[A, B](fa: Either[Err, A])(f: A => Either[Err, B]): Either[Err, B] =
fa.flatMap(f)
def pure[A](x: A): Either[Err, A] = Either.right(x)
}
So the flatMap
method is right-biased:
val right: Either[String, Int] = Either.right(5)
right.flatMap(x => Either.right(x + 1)) should be(res0)
val left: Either[String, Int] = Either.left("Something went wrong")
left.flatMap(x => Either.right(x + 1)) should be(res1)
Either
instead of exceptions As a running example, we will have a series of functions that will parse a string into an integer, take the reciprocal, and then turn the reciprocal into a string.
In exception-throwing code, we would have something like this:
object ExceptionStyle {
def parse(s: String): Int =
if (s.matches("-?[0-9]+")) s.toInt
else throw new NumberFormatException(s"${s} is not a valid integer.")
def reciprocal(i: Int): Double =
if (i == 0) throw new IllegalArgumentException("Cannot take reciprocal of 0.")
else 1.0 / i
def stringify(d: Double): String = d.toString
}
Instead, let's make the fact that some of our functions can fail explicit in the return type.
object EitherStyle {
def parse(s: String): Either[NumberFormatException, Int] =
if (s.matches("-?[0-9]+")) Either.right(s.toInt)
else Either.left(new NumberFormatException(s"${s} is not a valid integer."))
def reciprocal(i: Int): Either[IllegalArgumentException, Double] =
if (i == 0) Either.left(new IllegalArgumentException("Cannot take reciprocal of 0."))
else Either.right(1.0 / i)
def stringify(d: Double): String = d.toString
def magic(s: String): Either[Exception, String] =
parse(s).flatMap(reciprocal).map(stringify)
}
Do these calls return a Right
value?
EitherStyle.parse("Not a number").isRight should be(res0)
EitherStyle.parse("2").isRight should be(res1)
Now, using combinators like flatMap
and map
, we can compose our functions together. Will the following incantations return a Right
value?
import EitherStyle._
magic("0").isRight should be(res0)
magic("1").isRight should be(res1)
magic("Not a number").isRight should be(res2)
With the composite function that we actually care about, we can pass in strings and then pattern
match on the exception. Because Either
is a sealed type (often referred to as an algebraic data type,
or ADT), the compiler will complain if we do not check both the Left
and Right
case.
In the following exercise we pattern-match on every case the Either
returned by magic
can be in.
If we leave out any of those clauses the compiler will yell at us, as it should. However,
note the Left(_)
clause - the compiler will complain if we leave that out because it knows
that given the type Either[Exception, String]
, there can be inhabitants of Left
that are not
NumberFormatException
or IllegalArgumentException
. However, we "know" by inspection of the source
that those will be the only exceptions thrown, so it seems strange to have to account for other exceptions.
This implies that there is still room to improve.
import EitherStyle._
val result = magic("2") match {
case Left(_: NumberFormatException) => "Not a number!"
case Left(_: IllegalArgumentException) => "Can't take reciprocal of 0!"
case Left(_) => "Unknown error"
case Right(result) => s"Got reciprocal: ${result}"
}
result should be(res0)
Instead of using exceptions as our error value, let's instead enumerate explicitly the things that can go wrong in our program.
object EitherStyleWithAdts {
sealed abstract class Error
final case class NotANumber(string: String) extends Error
final case object NoZeroReciprocal extends Error
def parse(s: String): Either[Error, Int] =
if (s.matches("-?[0-9]+")) Either.right(s.toInt)
else Either.left(NotANumber(s))
def reciprocal(i: Int): Either[Error, Double] =
if (i == 0) Either.left(NoZeroReciprocal)
else Either.right(1.0 / i)
def stringify(d: Double): String = d.toString
def magic(s: String): Either[Error, String] =
parse(s).flatMap(reciprocal).map(stringify)
}
For our little module, we enumerate any and all errors that can occur. Then, instead of using
exception classes as error values, we use one of the enumerated cases. Now when we pattern
match, we get much nicer matching. Moreover, since Error
is sealed
, no outside code can
add additional subtypes which we might fail to handle.
import EitherStyleWithAdts._
val result = magic("2") match {
case Left(NotANumber(_)) => "Not a number!"
case Left(NoZeroReciprocal) => "Can't take reciprocal of 0!"
case Right(result) => s"Got reciprocal: ${result}"
}
result should be(res0)
Once you start using Either
for all your error-handling, you may quickly run into an issue where
you need to call into two separate modules which give back separate kinds of errors.
sealed abstract class DatabaseError
trait DatabaseValue
object Database {
def databaseThings(): Either[DatabaseError, DatabaseValue] = ???
}
sealed abstract class ServiceError
trait ServiceValue
object Service {
def serviceThings(v: DatabaseValue): Either[ServiceError, ServiceValue] = ???
}
Let's say we have an application that wants to do database things, and then take database
values and do service things. Glancing at the types, it looks like flatMap
will do it.
def doApp = Database.databaseThings().flatMap(Service.serviceThings)
This doesn't work! Well, it does, but it gives us Either[Object, ServiceValue]
which isn't
particularly useful for us. Now if we inspect the Left
s, we have no clue what it could be.
The reason this occurs is because the first type parameter in the two Either
s are different -
databaseThings()
can give us a DatabaseError
whereas serviceThings()
can give us a
ServiceError
: two completely unrelated types. Recall that the type parameters of Either
are covariant, so when it sees an Either[E1, A1]
and an Either[E2, A2]
, it will happily try
to unify the E1
and E2
in a flatMap
call - in our case, the closest common supertype is
Object
, leaving us with practically no type information to use in our pattern match.
So clearly in order for us to easily compose Either
values, the left type parameter must be the same.
We may then be tempted to make our entire application share an error data type.
sealed abstract class AppError
final case object DatabaseError1 extends AppError
final case object DatabaseError2 extends AppError
final case object ServiceError1 extends AppError
final case object ServiceError2 extends AppError
trait DatabaseValue
object Database {
def databaseThings(): Either[AppError, DatabaseValue] = ???
}
object Service {
def serviceThings(v: DatabaseValue): Either[AppError, ServiceValue] = ???
}
def doApp = Database.databaseThings().flatMap(Service.serviceThings)
This certainly works, or at least it compiles. But consider the case where another module wants to just use
Database
, and gets an Either[AppError, DatabaseValue]
back. Should it want to inspect the errors, it
must inspect **all** the AppError
cases, even though it was only intended for Database
to use
DatabaseError1
or DatabaseError2
.
Instead of lumping all our errors into one big ADT, we can instead keep them local to each module, and have an application-wide error ADT that wraps each error ADT we need.
sealed abstract class DatabaseError
trait DatabaseValue
object Database {
def databaseThings(): Either[DatabaseError, DatabaseValue] = ???
}
sealed abstract class ServiceError
trait ServiceValue
object Service {
def serviceThings(v: DatabaseValue): Either[ServiceError, ServiceValue] = ???
}
sealed abstract class AppError
object AppError {
final case class Database(error: DatabaseError) extends AppError
final case class Service(error: ServiceError) extends AppError
}
Now in our outer application, we can wrap/lift each module-specific error into AppError
and then
call our combinators as usual. Either
provides a convenient method to assist with this, called Either.leftMap
-
it can be thought of as the same as map
, but for the Left
side.
def doApp: Either[AppError, ServiceValue] =
Database.databaseThings().leftMap(AppError.Database).
flatMap(dv => Service.serviceThings(dv).leftMap(AppError.Service))
Hurrah! Each module only cares about its own errors as it should be, and more composite modules have their own error ADT that encapsulates each constituent module's error ADT. Doing this also allows us to take action on entire classes of errors instead of having to pattern match on each individual one.
def awesome =
doApp match {
case Left(AppError.Database(_)) => "something in the database went wrong"
case Left(AppError.Service(_)) => "something in the service went wrong"
case Right(_) => "everything is alright!"
}
Let's review the leftMap
and map
methods:
val right: Either[String, Int] = Right(41)
right.map(_ + 1) should be(res0)
val left: Either[String, Int] = Left("Hello")
left.map(_ + 1) should be(res1)
left.leftMap(_.reverse) should be(res2)
There will inevitably come a time when your nice Either
code will have to interact with exception-throwing
code. Handling such situations is easy enough.
val either: Either[NumberFormatException, Int] =
try {
Either.right("abc".toInt)
} catch {
case nfe: NumberFormatException => Either.left(nfe)
}
However, this can get tedious quickly. Either
provides a catchOnly
method on its companion object
that allows you to pass it a function, along with the type of exception you want to catch, and does the
above for you.
val either: Either[NumberFormatException, Int] =
Either.catchOnly[NumberFormatException]("abc".toInt)
If you want to catch all (non-fatal) throwables, you can use catchNonFatal
.
Either.catchOnly[NumberFormatException]("abc".toInt).isRight should be(res0)
Either.catchNonFatal(1 / 0).isLeft should be(res1)
For using Either's syntax on arbitrary data types, you can import cats.implicits._
. This will
make possible to use the asLeft
and asRight
methods:
import cats.implicits._
val right: Either[String, Int] = 7.asRight[String]
val left: Either[String, Int] = "hello 🐈s".asLeft[Int]
These method promote values to the Either
data type:
import cats.implicits._
val right: Either[String, Int] = 42.asRight[String]
right should be(res0)