Error composition with Shapeless coproducts
TL; DR
Shapeless coproduct is much better option for error modelling in Either[L, R]
or EitherT[F, L, R]
then regular sealed /sum structures because unlike them:
- guarantees handling of all type of errors — no
match is not exhastive
warning, only compilation errors; - better errors composition;
Introduction
Using exception in Scala for error handling often considered as not very good engineering practise, in most of the cases, because it’s not type-safe: when you work with Try
or some effect F[_]
and want handle Throwable
threw from downstream invocations, you never sure that you handling all possible erros because Throwable
is not sealed structure. Also, it is worth to mention that creating exception, because of capturing stack trace, might be non-cheap operation and overall function which can throw any exception considered as not pure (because throwing exception is side effect). As an alternative, good practise considered Either
or F[Either[_, _]]
or handy transformer EitherT. How to work with error in this case? You can put any ADT inEither
's left type and your are on safe side. Ok, but what about composition of such errors? Let's dive in.
Example
For sake of example let’s consider issue tracking system, with the simplest model: we have users, who can create or update tickets. Let’s omit technical details and try to define API for simple service to perform basic actions over tickets — get and update:
So far so good? Well, we can see first red flag: very similar *TicketNotExists
and *TicketIllegalAccess
objects, which exists in different context. Let's put this on stack and get back to this later.
First composition attempt
Ok we have primitive methods, which we can re-use for implementing more complicated logic, e.g: change status for a ticket: all we need is just get ticket, change status and update it. The first and naive implementation you can think of, might look like (rest of code omitted):
Here another problem appears: two EitherT
‘s can't be used in for comprehension construction, because EitherT[GetError, _]
and EitherT[UpdateError, _]
has different left types. So, what we can do? Well, we can introduce a common ancestor for instance and return more general error. Yes, but this does not sound like a good way to go: What if later we will need another errors' composition?
Introducing coproducts
In order to build our error ADT model in more composable way let’s look at Shapeless coproduct: instead of having hierarchical structure of errors per operation — we can have flattened general purpose error models, which then compose per operation via Coproduct
, like:
What do we have now:
- Instead of
GetError
andUpdateError
sum types, we have now aliases forCoproduct
of errors; - We don’t need to duplicated similar
*TicketNotExists
and*TicketIllegalAccess
objects in different hierarchies, because structure of low-lever errors now flat.
Composition with coproducts
Ok, now we can proceed to most interesting — how compose errors with Copructs
? So, getting back to updateStatus
implementation - what we want to achieve at the end? Or what's the return type we expect we should have? If we think that we would like to rethrow those errors to upper abstraction layer (like HTTP controller), then we would like to return type representing all our possible errors - and again this is Copruduct
:
Let’s use this error type to re-implement updateStatus
:
Handling coproduct errors
Ok, now we have service which can return type-safe model of all possible errors, how we can handle them? Coproduct like many other structures can be folded via Poly1
function. Let's consider abstract HTTP controller, which consumes TicketsService
API and handle's it errors:
Handling of new errors
For sealed structures folding, the only choice we have — pattern matching. If we will add new class or object into sealed family, but will not add another case
compiler will give us famous warning: match is not exhaustive!
. Which is good, but with Coproducts we can get stronger guarantees of proper error handling.
Let’s say we now want to return one more error — ticket statuses should obey some flow, e.g. Deployed to Production
can’t be changed to In progress
. Let’s update model first (rest of code ommited):
If we try to compile project, without any othe changes, we will get next compilation error in TicketsController.handleErros.atUpdateError
— line #26:
[error] ... : could not find implicit value for parameter folder: shapeless.ops.coproduct.Folder[TicketsController.this.handlePrimitiveErrors.type,post.TicketsService.UpdateError]
[error] implicit val atUpdateError: Case.Aux[UpdateError, Response] = at(_.fold(handlePrimitiveErrors))
Which is great, because compiler actually shows that we have unhandled error.
Thank you!
Thank you for reading this material, your feedback is more than welcome, I really wonder what do you think!
References
Next Stackoverflow posts and articles was used to write this post.