Because error handling belongs in the types.
This project compiles to Scala 3.x and Scala 2.13.
Add it as an sbt dependency:
libraryDependencies += "info.umazalakain" %% "errata" % version
There is a video presentation with this ideas, and also some slides to go with it.
The approach to error handling taken by cats suffers from several shortcomings.
Assume a method with signature def method[F[_]: ApplicativeError[*, AppError], A](fa: F[A]): F[A]
.
It might be the case that method
raises errors, hence why ApplicativeError[F, AppError]
is necessary.
Or, it might not raise any errors, and rather handle them.
From the signature alone, we cannot deduce whether method
raises errors, handles them, or does both.
By itself, error handling is imprecise too.
ApplicativeError.attempt
transforms an F[A]
into an F[Either[E, A]]
, which has two error channels: Either[E, *]
where errors are reported as Left
; and F
itself, still capable of raising errors.
The fact that errors were handled is not reflected in the effect types.
Moreover, managing multiple error types is highly impractical:
using multiple implicits ApplicativeError[F, E1]
and ApplicativeError[F, E2]
results in ambiguous implicit resolution, since both extend Applicative[F]
.
Indeed, one can extend E1
and E2
with Throwable
and substitute both ApplicativeError[F, E1]
and ApplicativeError[F, E2]
with ApplicativeThrow[F]
(ApplicativeError[F, Throwable]
).
In this case, we lose information about the types of errors we raise, and must now deal with all errors of type Throwable
.
⚠️ As a consequence of all of the above, services based on error handling à la cats are brittle and unnecessarily prone to runtime crashes: it becomes impossible to track which modules raise what errors, and the compiler cannot ensure that errors are appropriately dealt with.
Cats-mtl takes a step in the right direction by defining types Raise[F[_], E]
and Handle[F[_], E]
.
However, we notice that Handle[F, E]
1) extends Raise[F, E]
and 2) doesn't bind an output effect — like errata's HandleTo[F, G, E]
does.
As a consequence, we cannot limit a function to error handling only (it will be able to raise errors too), and we cannot use effect types to keep track of error handling (the output effect type will be the same).
Errata provides solutions to both these shortcomings.
This project exposes the error handling capabilities provided by ToFu. As such, their code is at times shared verbatim.
We differentiate between programs that raise errors, and programs that handle them.
The type Raise[F, E]
tells us that we know how to raise errors of type E
inside an effect F[_]
.
trait Raise[F[_], E] {
def raise[A](err: E): F[A]
}
The type HandleTo[F, G, E]
tells us that we know how to transform an effect F[_]
into an effect G[_]
by handling all errors of type E
.
trait HandleTo[F[_], G[_], E] {
def handleWith[A](fa: F[A])(f: E => G[A]): G[A]
}
For any type A
, we are able to transform all values of type F[A]
into values of type G[A]
merely by handling the error cases with E => G[A]
.
That is, all errors of type E
are dealt with by the time we reach G
.
We provide further convenience methods and bundles of types. You may check them out in more detail here.
Handle[F[_], E]
: equivalent toHandleTo[F, F, E]
, plus convenience methods.ErrorsTo[F[_], G[_], E]
: equivalent toRaise[F, E]
plusHandleTo[F, G, E]
--- often what you want.Errors[F[_], E]
: equivalent toErrorsTo[F, F, E]
plus convenience methods.TransformTo[F[_], G[_], E1, E2]
: equivalent toHandleTo[F, G, E1]
plusRaise[G, E2]
, plus convenience methods.
We also define type aliases HandleToThrow
, HandleThrow
, ErrorsToThrow
, and ErrorsThrow
: they instantiate the E
error parameter of their respective types to Throwable
.
We provide convenience syntax for error types E
and effect types F[A]
.
It can be brought into scope with:
import errata.syntax.all.*
Sometimes this syntax can conflict with the syntax of cats or other libraries.
As a workaround, we provide a namespaced version, where you use .withErrata
to bring into scope errata's syntax.
Import it with:
import errata.syntax.namespaced.*
As an example, we derive instances Raise
and HandleTo
for the concrete type Either[E, _]
.
We provide these instances by combining them into one single instance ErrorsTo[Either[E, _], Id, E]
.
This shows that:
- for any value type
A
we can useEither[E, A]
to represent errors; and that - for any value type
A
we can transform values of typeEither[E, A]
into values of typeId[A]
(akaA
) by handling errors of typeE
.
final implicit def eitherInstance[E]: ErrorsTo[Either[E, _], Id, E] =
new ErrorsTo[Either[E, _], Id, E] {
override def raise[A](err: E): Either[E, A] =
Left(err)
override def handleWith[A](fa: Either[E, A])(f: E => Id[A]): Id[A] =
fa.fold(f, identity)
}
Full interoperability with cats and its ApplicativeError
and ApplicativeThrow
is provided in errata.instances.*
.
Check out the examples.
To derive an Errors[F, E]
instance given an cats.Applicative[F]
:
import errata.instances.*
implicit val errorsFE: Errors[F, E] = errorsThrowable(classTag[E])
All instances must satisfy certain algebraic laws to be considered well behaved.
We use discipline to perform quickcheck-style checking of these laws. The laws are grouped into discipline bundles and tested against concrete types. Given a custom concrete type and its corresponding error raising/handling instances, you can verify they are lawful by running them on the existing discipline bundles.
To execute the tests simply run sbt test
.
import cats.effect.{ExitCode, IO, IOApp}
import cats.effect.std.Console
import cats.syntax.all.*
import cats.{Applicative, MonadThrow}
import errata.*
import errata.syntax.all.*
import errata.instances.*
/*
This example demonstrates the interoperability between this project and cats errors.
- application top-level uses cats errors
- http client uses cats errors
- application logic uses _errata_ only
+-------------------+
| IO (cats) |
+-------------------+
| https | app |
| client | logic |
| (cats) | (errata) |
+--------+----------+
*/
object httpClient extends IOApp {
// Http4s client (raises cats errors with MonadThrow)
trait HttpClient[F[_]] {
def run[A]: F[A]
}
object HttpClient {
def apply[F[_]](implicit F: MonadThrow[F]): HttpClient[F] =
new HttpClient[F] {
override def run[A]: F[A] =
F.raiseError(new Throwable("Some kind of error"))
}
}
// Application-wide custom error types
sealed trait AppError
case class RestAPIError(th: Throwable) extends AppError
case class GraphQLError(th: Throwable) extends AppError
// The http client produces effects of type F
// TransformTo[F, G, Throwable, AppError] guarantees that:
// all errors of type Throwable in F are transformed into errors of type AppError in G
// HandleTo[G, H, AppError] guarantees that:
// all errors of type AppError are handled and gone from H
// The lack of an instance Raise[H, E] guarantees that:
// the resulting effect H raises no errors at all
def appLogic[F[_], G[_]: Applicative, H[_]: Console, A](
httpClient: HttpClient[F]
)(implicit
transformTo: TransformTo[F, G, Throwable, AppError],
handleTo: HandleTo[G, H, AppError]
): H[Unit] = {
val apiResponse: G[A] = httpClient.run[A].transform(RestAPIError.apply)
val graphqlResponse: G[A] = httpClient.run[A].transform(GraphQLError.apply)
(apiResponse, graphqlResponse)
.mapN {
// Handle happy case
case (_, _) => ()
}
.handleWith[H, AppError] {
// Handle errors
case RestAPIError(th) =>
Console[H].println(s"REST API error: ${th.getMessage}")
case GraphQLError(th) =>
Console[H].println(s"GraphQL error: ${th.getMessage}")
}
}
def run(args: List[String]): IO[ExitCode] = {
// Fully cats compatible
// Automatically derives instances of TransformTo[IO, IO, Throwable, AppError] and HandleTo[IO, IO, AppError]
implicit val appErrors: Errors[IO, AppError] = errorsThrowable(classTag[AppError])
IO.println("Expecting a properly handled error") *>
appLogic[IO, IO, IO, Unit](HttpClient[IO]).as(ExitCode.Success)
}
}