On EitherT

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.

data Either a b
  = Left a
  | Right b

where typically Left represents some failure case in this context and Right represents success. Another formulation from OCaml community is:

type ('a,'b) result
  = Ok of 'a
  | Error of 'b

which is more explicit about what the two constructors represent.

Async Exceptions

Back in the beginning, actually in 2000/01, asynchronous exceptions were added to Haskell. [2] 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 async exception.

So, how should we best deal with this reality and structure our code?

Exceptions

We have exceptions; lets use them.

To start doing that you need to define your own custom exception type.

data VapourError =
    InsufficientFunds
  | ItemUnavailable Text
  | MachineMalfunction Text
  deriving Typeable

-- Write a reasonable Show instance for each error
instance Show VapourError where
  show a = case a of
    InsufficientFunds -> "Insufficient funds."
    ItemUnavailable i -> "Item " ++ i ++ " unavailable."
    MachineMalfunction e -> "Hardware malfunction " ++ e ++ "."

instance Exception VapourError

The three steps we need are:

  1. Build the custom error type as a data type
  2. Provide a show instance, this could be generated but your error messages would not be great.
  3. Make your custom error an instance of Exception

At this point you can use throw, catch and handle with your custom error type.

runVendingMachine :: VendingMachineState -> Coin
                   -> IO Product
runVendingMachine state coin = do
  unless (coin > 0) $ throw InsufficientFunds
  dispenseItem state coin

dispenseItem :: VendingMachineState -> Coin
             -> IO VendingMachineState
dispenseItem = ....

Looking at the signature of runVendingMachine you can see that it returns a Product by running a computation in 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 safe-exception is 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 StackOverflow, HeapOverflow and ThreadKilled 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 a catch or 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 back to Javascript or Ruby land and giving up on some of the benefits of a typed language. I want the types to 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.

Data Types

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 an Either where the monad could be anything.

In context it would look something like:

crankHandle :: Int -> EitherT VapourError IO Product
-- or
crankHandle :: Monad m => Int -> EitherT VapourError m Product
-- or
crankHandle :: MonadIO m => Int -> EitherT VapourError m Product

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.

Solution

# Build a data type that represents the possible error states
data VapourError =
    InsufficientFunds
  | ItemUnavailable Text
  | MachineMalfunction Text

# Provide a function for turning errors into text
renderVapourError :: VapourError -> Text
renderVapourError = ...

# Usage site
runVendingMachine :: VendingMachineState -> Coin -> EitherT VapourError IO Product
runVendingMachine = ...

Either - Examples

Examples of substantial pieces of code using EitherT to organise errors.

  • mafia - https://github.com/haskell-mafia/mafia/search?utf8=✓&q=EitherT&type=
  • boris - https://github.com/markhibberd/boris/search?utf8=✓&q=EitherT&type=
  • traction - https://github.com/markhibberd/traction/search?utf8=✓&q=EitherT&type=
  • mismi - https://github.com/nhibberd/mismi/search?q=EitherT&type=Code&utf8=✓

Either Advantages

  • 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

Exception Disadvantages

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.

Supporting Libraries

The supporting libraries for this pattern of error handling are:

  • transformers-either - Provides a type alias type EitherT = ExceptT plus 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.

Conclusion

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

References

  1. Asynchronous Exceptions in Practice
  2. Asynchronous Exceptions in Haskell
  3. Exceptions Best Practices in Haskell
  4. The RIO Monad
  5. Yaron’s Thoughts
  6. Checked Exceptions
Copyright © Tim McGilchrist 2007-2024
Powered by Hakyll