When dealing with errors in monadic computations, common practice is to use a single most common error type to define
the possible error outcomes (via EitherT
or a like monad transformer or by incorporating the error type directly in
the monad, like Throwable
for IO
of cats-effect or user-specified type for ZIO's ZIO
).
As the result, to preserve monad composability, all errors must be subtypes of the chosen common type, even though they may belong to different areas of the business domain.
Error handling assumes the handling code takes an argument of the common error type, which is essentially as bad a
practice as try { .. } catch { case ex: Throwable => .. }
in imperative Scala.
This project is a proof of concept bringing a more type-safe error handling, close to one provided by Java's checked exceptions.
The proposed approach is to use coproduct type (here, Coproduct
from shapeless library) to encode the possible error
outcomes in EitherT
monad transformer (the concept may be generalized onto monad families such as ZIO
, which are
already parameterized by error type).
It will then be possible to handle a particular type of error, which would exclude it and its subtypes from the
coproduct type, much like catch
statement in Java excludes types from method's throws
declaration. The difference
from catch
statement is that no runtime checks for the type are performed: a type element of coproduct is considered
handled if and only if it is provable at compile time that the handled error type is the supertype of the element type.
Example.
Let type ErrorCode = Int
.
Let error type E = String :+: ErrorCode :+: Throwable :+: CNil
, handled error type H = AnyRef
.
After handling, the resulting error type R = ErrorCode :+: CNil
, since the compiler is able to prove
String <: AnyRef
and Throwable <: AnyRef
.
Example.
Let T1
and T2
be traits and type C <: T1 with T2
. Let error type E = T1 :+: T2 :+: CNil
. If an error of type C
occurs, it will only be guaranteed to be handled if both T1
and T2
are handled.
Having monad parameterized by varying error type breaks composability, since we essentially are working with a different
monad for each given error type.
The proposed solution is to define a merge operation over monads.
Definition. Let ℳ
be a set of monads. Let μ
map any two monads M₁ ∈ ℳ
and M₂ ∈ ℳ
to a triplet (R, N₁, N₂)
where R ∈ ℳ
, N₁
is a natural transformation from M₁
to R
, and N₂
is a natural transformation from M₂
to R
.
We call the pair (ℳ, μ)
a mergeable monad set.
This structure enables us to generalize flatMap
to different monads from ℳ
. We introduce flatMapMerge
operation:
flatMapMerge(m1: M₁[A], f: A => M₂[B]): R[B] = flatMap(N₁(m1), a => N₂(f(a)))
.
We implement mergeable monad set as a type family MonadMerge[M[_, _], C[_, _, _]]
.
M[_, _]
is a monad family, M[L, *]
being a monad for each type L
admissible as the left type parameter.
C[_, _, _]
is a type family encoding the merge relation between monads in M
. For each two admissible
monad-parameterizing types L1
and L2
there must exist a single admissible monad-parameterizing type R
such that an
implicit instance of C[L1, L2, R]
is defined.
For error handling, we use EitherT[F, *, *]
as M
, F[_]
being the undelying monad. The admissible
monad-parameterizing types are shapeless.Coproduct
subtypes. Parameterization with CNil
means that no errors may
occur in the left channel.
C
is implemented using implicit macros (see fundep materialization).
One set of possible error types should normally be represented by the same Coproduct
type. That is,
- types constituting the coproduct should be ordered deterministically;
- if type
A
is an element of error coproduct type, then no other type elementB
in this coproduct may be such thatA <: B
is provable.
Coproducts not satisfying these conditions do not arise when merging two error coproducts, since the implementation takes care of yielding a normalized type.
Non-normalized coproducts may still occur when
- manually constructing
EitherT[L <: Coproduct, A]
;L
may be an arbitrary non-normalized coproduct. - working with generic error types.
Example.
Suppose this function is defined:
// the inferred result type depends on type parameter A:
def useDelegate[A](delegate: EitherT[IO, A :+: CNil, Array[Byte]])
: EitherT[IO, String :+: A :+: CNil, Array[Byte]] =
delegate >>+ { bytes =>
leftToCoproduct(EitherT.cond[IO](
bytes.length < (1 << 20),
bytes,
"Array is too long."))
}
At the call site:
val result: EitherT[IO, String :+: String :+: CNil, Array[Byte]] =
useDelegate(leftToCoproduct(
EitherT.leftT[IO, Array[Byte]]("Could not fetch the array.")))
the resulting error type becomes non-normalized String :+: String :+: CNil
, since A
is String
.
As merge result must be normalized, we additionally require that, given a mergeable monad set (ℳ, μ)
,
μ(M₁, M₂) = (R, N₁, N₂) => μ(R, R) = (R, id, id)
.
Normalization may be manually performed via normalize
when needed.