In choosing Haskell as a language you sign up for a certain class of features and behaviours. e.g. lazy evaluation, static typing
This gives you a general point in the design space for general purpose languages but like all languages you are still left with a number of choices in building software. These choices are broad, diverse and hotly debated, sometimes they get labelled with Best Practices or the Right way. Like any good engineer you should recognise that everything involves trade-offs and that these labels are trying to hide that. There is not always one best way, an approach has positives and negatives. Knowing those trade offs and deliberately choosing an approach based off them is good engineering.
In programming language communities there are always bikeshedding arguments and Haskell is no different. I want to call out a particular point of view around using exceptions vs data types in Haskell when dealing with errors. Both are valid design points in a wider error handling design space. The exception path is widely associated with Snoyman, who has written much software and written extensively about this in Exceptions Best Practices in Haskell and in the Safe Exceptions package.
I’d like to highlight the negatives, as I see them, of that approach and suggest a different set of trade offs around modelling errors as data types using EitherT/ExceptT.
EitherT is a Monad Transformer built on the familar Either data type.
1 2 3
Left represents some failure case in this context and
Right represents success.
Another formulation from OCaml community is:
1 2 3
which is more explicit about what the two constructors represent.
Back in the beginning, actually in 2000/01, asynchronous exceptions were added to Haskell.  Quoting Simon Marlow:
Basically it comes down to this: if we want to be able to interrupt purely functional code, asynchronous exceptions are the only way, because polling would be a side-effect.
So Haskell has async exceptions whether you like them or not, the ship has sailed.
This means that any code in
IO can throw a runtime exception, further any thread can receive an
So, how should we best deal with this reality and structure our code?
We have exceptions; lets use them.
To start doing that you need to define your own custom exception type.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
The three steps we need are:
- Build the custom error type as a data type
- Provide a show instance, this could be generated but your error messages would not be great.
- Make your custom error an instance of
At this point you can use
handle with your custom error type.
1 2 3 4 5 6 7 8 9 10
Looking at the signature of
runVendingMachine you can see that it returns a
Product by running a
IO. The problem you have when looking at that code is the signature doesn’t
give you any indication that it might fail outside of the
IO which we saw earlier can fail with anything.
So as a consumer of this function, how are you to know what exceptions to catch. Your options are:
- Catch all exceptions – clearly dangerous and wrong
- Catch a subset of exceptions – better but tricky to do correctly
The first option is dangerous as catching all exceptions includes asynchronous exceptions like
stack/heap overflow, thread killed and user interrupt. The documentation in
particularly helpful here and I recommend you read it thoroughly, it is well written. The short version is
you should only catch certain exceptions, trying to handle
exceptions could cause your program to crash or behave in unexpected ways.
The second option is error prone. The process for finding the possible exceptions involves reading the source code
and reading the haddock docs, with the goal of finding the set of sensible exceptions you need to put into
handle call. Have you found all the places an exception might be thrown? What about if you
pull in a new dependency, does it throw exceptions? What about a sub-dependency of a dependency?
What about the functions
runVendingMachine calls? And their functions? To me it feels like going
help me find the places I need to consider the errors, just like pattern matching does for data types.
The other less obvious (perhaps) issue is that you force the consumers of your function to know all the gory details of exceptions in Haskell, which ones are safe to catch and what to do. Getting this right is hard and tricky, and really belongs in a library so that it can be written one and reused.
Finally the behaviour of a Haskell system in production is such that throwing an exception would yield you exactly
what the show instance for
VapourError is. It wouldn’t give you a classic stack trace (unless you set that up)
so you loose context where the exception was raised and what was happening around it. At a previous workplace we
spend many weeks tracking down SSL and connection reset exceptions that occured in a base library but bubbled
out through multiple layers of application code. It wasn’t fun.
This style is perfect for a quick script to munge some data, or an ICFP programming contest
If you really need exceptions, use
bracket pattern or
safe-exceptions like library. Keep the
complexity contained and code needs to be written very carefully.
We mentioned data types earlier, using data types to model your computation is the natural approach in Haskell.
You build a data type that accurately reflects the data or states that you want to model. We even
did it for the custom
VapourError type earlier.
Extending that we will use a particular data type
EitherT to model errors. This is a
monad transformer with
Either where the monad could be anything.
In context it would look something like:
1 2 3 4 5
The type of our error is present in the type of our function, a familar situation.
If the monad
m isn’t IO then we have a good degree of confidence that
none of the base
exceptions will be present.
1 2 3 4 5 6 7 8 9 10 11 12 13
Either – Examples
Examples of substantial pieces of code using
EitherT to organise errors.
- mafia – https://github.com/haskell-mafia/mafia/search?utf8=%E2%9C%93&q=EitherT&type=
- boris – https://github.com/markhibberd/boris/search?utf8=%E2%9C%93&q=EitherT&type=
- traction – https://github.com/markhibberd/traction/search?utf8=%E2%9C%93&q=EitherT&type=
- mismi – https://github.com/nhibberd/mismi/search?q=EitherT&type=Code&utf8=%E2%9C%93
- function signatures clearly indicate error states
- exhaustive pattern matching indicates where errors have/have not been handled
- requires explicit composition of error data types
Basically the compiler helps you handle the various states required using the type system.
Exception – Examples
Example of code using
Exceptions to organise errors
- http-client – using non-200 response codes as exceptions
- stack – internally follows an exception style
The main downsides as I see it to exception oriented code are:
- exception throwing functions compose too easily you are not forced to think about what it means.
- no stack traces by default in Haskell mean you lose context.
- handling exceptions requires knowledge about the internals of dependencies and how they use exceptions.
Here the compiler is less helpful in guiding you, giving little or no help with handling particular exceptions or giving compile errors for new exceptions that you might need to consider.
The supporting libraries for this pattern of error handling are:
- transformers-either – Provides a type alias
type EitherT = ExceptTplus addition operators.
- transformers-bifunctor – Provies bifunctors over a monad transformer.
There is nothing revolutionary about
transformers-either, you could roll your own version
easily or use the
ExceptT transformer provided in the
transformers package (adding any helper
functions you need). The value codes in a structured, consious handling of errors and using the
Haskell compiler to help.
The primary value of avoiding exceptions is that it makes error behavior explicit in the type of the function. If you’re in an environment where everything might fail, being explicit about it is probably a negative. But if most of your function calls are total, then knowing which ones might fail highlights places where you should consider what the correct behavior is in the case of that failure. Remember that the failure of an individual step in your program doesn’t generally mean the overall failure of your code.
It’s a little bit like null-handling in languages without options. If everything might be null, well, option types probably don’t help you. But if most of the values you encounter in your program are guaranteed to be there, then tracking which ones might be null be tagging them as options is enormously helpful, since it draws your attention to the cases where it might be there, and so you get an opportunity to think about what the difference really is.
- Yaron Minsky