Error composition with Shapeless coproducts

Ivan Kurchenko
4 min readFeb 5, 2021

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 and UpdateError sum types, we have now aliases for Coproduct 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.

--

--