Sunday, March 29, 2015

Is ExceptT over IO really an anti-pattern ?

I was reading this exceptions best practice guide for Haskell. It contains good advice, for example about how masking all exceptions is a terrible idea. However, I sort of disagree about the "ExceptT over IO being an antipattern" part.

One of the reasons given is that "it's non-composable". I actually think that making errors explicit in the types can be more composable, especially if you want to combine or transform the errors in some  manner. Wrapping an exception to provide additional context tends to be more cumbersome than mapping over the error type with withExceptT (or first from Bifunctor).

When you compose multiple exception-throwing functions, if you aren't careful your result funcion will end up  (maybe even unbeknownst to you!) throwing a zoo of unrelated exceptions with scant context.

(Digression: the recent pipes-cliff library actually does a good job in wrapping every IOException that it encounters, tagging it with extra information.)

On the other hand, I admit that making all the errors explicit in the signature complicates it, and it becomes annoying if you consider those errors as "fatal" anyway and you don't intend to handle them, or at least not at the current layer.

Which brings us to the other main criticism: that using "ExceptT over IO" doesn't really ensure the absence of exceptions, even if it seems to imply it, and that it only provides a second, largely redundant, channel for communincating errors. In my opinion, there can be cases when we might want two separate error channels: if we want to distinguish between outright fatal vs. recoverable errors that we may want to handle, for example. Or between errors we want to annotate/map over/compose easily versus errors we just want to pass to upper layers unchanged.

In my process-streaming and conceit libraries I actually employ this dual approach. The execution functions re-throw any IO exceptions they encounter, but you can also have an explicit error type. If case the error type is never used, it remains polymorphic and we can use functions that have a simpler signature but require the error type to unify with Void.