The Trouble with Typed Errors

You, like me, program in either Haskell, or Scala, or F#, or Elm, or PureScript, and you don’t like runtime errors. They’re awful and nasty! You have to debug them, and they’re not represented in the types. Instead, we like to use Either (or something isomorphic) to represent stuff that might fail:

data Either l r = Left l | Right r

Either has a Monad instance, so you can short-circuit an Either l r computation with an l value, or bind it to a function on the r value.

So, we take our unsafe, runtime failure functions:

head   :: [a] -> a
lookup :: k -> Map k v -> v
parse  :: String -> Integer

and we use informative error types to represent their possible failures:

data HeadError = ListWasEmpty

head :: [a] -> Either HeadError a

data LookupError = KeyWasNotPresent

lookup :: k -> Map k v -> Either LookupError v

data ParseError
    = UnexpectedChar Char String
    | RanOutOfInput

parse :: String -> Either ParseError Integer

Except, we don’t really use types like HeadError or LookupError. There’s only one way that head or lookup could fail. So we just use Maybe instead. Maybe a is just like using Either () a – there’s only one possible Left () value, and there’s only one possible Nothing value. (If you’re unconvinced, write newtype Maybe a = Maybe (Either () a), derive all the relevant instances, and try and detect a difference between this Maybe and the stock one).

But, Maybe isn’t great – we’ve lost information! Suppose we have some computation:

foo :: String -> Maybe Integer
foo str = do
    c <- head str
    r <- lookup str strMap
    eitherToMaybe (parse (c : r))

Now, we try it on some input, and it gives us Nothing back. Which step failed? We actually can’t know that! All we can know is that something failed.

So, let’s try using Either to get more information on what failed. Can we just write this?

foo :: String -> Either ??? Integer
foo str = do
    c <- head str
    r <- lookup str strMap
    parse (c : r)

Unfortunately, this gives us a type error. We can see why by looking at the type of >>=:

(>>=) :: (Monad m) => m a -> (a -> m b) -> m b

The type variable m must be an instance of Monad, and the type m must be exactly the same for the value on the left and the function on the right. Either LookupError and Either ParseError are not the same type, and so this does not type check.

Instead, we need some way of accumulating these possible errors. We’ll introduce a utility function mapLeft that helps us:

mapLeft :: (l -> l') -> Either l r -> Either l' r
mapLeft f (Left l) = Left (f l)
mapLeft _ r = r

Now, we can combine these error types:

foo :: String
    -> Either
        (Either HeadError (Either LookupError ParseError))
        Integer
foo str = do
    c <- mapLeft Left (head str)
    r <- mapLeft (Right . Left) (lookup str strMap)
    mapLeft (Right . Right) (parse (c : r))

There! Now we can know exactly how and why the computation failed. Unfortunately, that type is a bit of a monster. It’s verbose and all the mapLeft boilerplate is annoying.

At this point, most application developers will create a “application error” type, and they’ll just shove everything that can go wrong into it.

data AllErrorsEver
     = AllParseError ParseError
     | AllLookupError LookupError
     | AllHeadError HeadError
     | AllWhateverError WhateverError
     | FileNotFound FileNotFoundError
     | etc...

Now, this slightly cleans up the code:

foo :: String -> Either AllErrorsEver Integer
foo str = do
    c <- mapLeft AllHeadError (head str)
    r <- mapLeft AllLookupError (lookup str strMap)
    mapLeft AllParseError (parse (c : r))

However, there’s a pretty major problem with this code. foo is claiming that it can “throw” all kinds of errors – it’s being honest about parse errors, lookup errors, and head errors, but it’s also claiming that it will throw if files aren’t found, “whatever” happens, and etc. There’s no way that a call to foo will result in FileNotFound, because foo can’t even do IO! It’s absurd. The type is too large! And I have written about keeping your types small and how wonderful it can be for getting rid of bugs.

Suppose we want to handle foo’s error. We call the function, and then write a case expression like good Haskellers:

case foo "hello world" of
    Right val ->
        pure val
    Left err ->
        case err of
            AllParseError parseError ->
                handleParseError parseError
            AllLookupError lookupErr ->
                handleLookupError
            AllHeadError headErr ->
                handleHeadError
            _ ->
                error "impossible?!?!?!"

Unfortunately, this code is brittle to refactoring! We’ve claimed to handle all errors, but we’re really not handling many of them. We currently “know” that these are the only errors that can happen, but there’s no compiler guarantee that this is the case. Someone might later modify foo to throw another error, and this case expression will break. Any case expression that evaluates any result from foo will need to be updated.

The error type is too big, and so we introduce the possibility of mishandling it. There’s another problem. Let’s suppose we know how to handle a case or two of the error, but we must pass the rest of the error cases upstream:

bar :: String -> Either AllErrorsEver Integer
bar str =
    case foo str of
        Right val -> Right val
        Left err ->
            case err of
                AllParseError pe ->
                    Right (handleParseError pe)
                _ ->
                    Left err

We know that AllParseError has been handled by bar, because – just look at it! However, the compiler has no idea. Whenever we inspect the error content of bar, we must either a) “handle” an error case that has already been handled, perhaps dubiously, or b) ignore the error, and desperately hope that no underlying code ever ends up throwing the error.

Are we done with the problems on this approach? No! There’s no guarantee that I throw the right error!

head :: [a] -> Either AllErrorsEver a
head (x:xs) = Right x
head [] = Left (AllLookupError KeyWasNotPresent)

This code typechecks, but it’s wrong, because LookupError is only supposed to be thrown by lookup! It’s obvious in this case, but in larger functions and codebases, it won’t be so clear.

Monolithic error types are bad

So, having a monolithic error type has a ton of problems. I’m going to make a claim here:

All error types should have a single constructor

That is, no sum types for errors. How can we handle this?

Let’s maybe see if we can make Either any nicer to use. We’ll define a few helpers:

type (+) = Either
infixr + 5

l :: l -> Either l r
l = Left

r :: r -> Either l r
r = Right

Now, let’s refactor that uglier Either code with these new helpers:

foo :: String
    -> Either
        (HeadError + LookupError + ParseError)
        Integer
foo str = do
    c <- mapLeft l (head str)
    r <- mapLeft (r . l) (lookup str strMap)
    mapLeft (r . r) (parse (c : r))

Well, the syntax is nicer. We can case over the nested Either in the error branch to eliminate single error cases. It’s easier to ensure we don’t claim to throw errors we don’t – after all, GHC will correctly infer the type of foo, and if GHC infers a type variable for any +, then we can assume that we’re not using that error slot, and can delete it.

Unfortunately, there’s still the mapLeft boilerplate. And expressions which you’d really want to be equal, aren’t –

x :: Either (HeadError + LookupError) Int
y :: Either (LookupError + HeadError) Int

The values x and y are isomorphic, but we can’t use them in a do block because they’re not exactly equal. If we add errors, then we must revise all mapLeft code, as well as all case expressions that inspect the errors. Fortunately, these are entirely compiler-guided refactors, so the chance of messing them up is small. However, they contribute significant boilerplate, noise, and busywork to our program.

Boilerplate be gone!

Well, turns out, we can get rid of the order dependence and boilerplate with type classes! The most powerful approach is to use “classy prisms” from the lens package. Let’s translate our types from concrete values to prismatic ones:

-- Concrete:
head :: [a] -> Either HeadError a

-- Prismatic:
head :: AsHeadError err => [a] -> Either err a


-- Concrete:
lookup :: k -> Map k v -> Either LookupError v

-- Prismatic:
lookup
    :: (AsLookupError err)
    => k -> Map k v -> Either err v

Now, type class constraints don’t care about order – (Foo a, Bar a) => a and (Bar a, Foo a) => a are exactly the same thing as far as GHC is concerned. The AsXXX type classes will automatically provide the mapLeft stuff for us, so now our foo function looks a great bit cleaner:

foo :: (AsHeadError err, AsLookupError err, AsParseError err)
    => String -> Either err Integer
foo str = do
    c <- head str
    r <- lookup str strMap
    parse (c : r)

This appears to be a significant improvement over what we’ve had before! And, most of the boilerplate with the AsXXX classes is taken care of via Template Haskell:

makeClassyPrisms ''ParseError
-- this line generates the following:

class AsParseError a where
    _ParseError :: Prism' a ParseError
    _UnexpectedChar :: Prism' a (Char, String)
    _RanOutOfInput :: Prism' a ()

instance AsParseError ParseError where
    -- etc...

However, we do have to write our own boilerplate when we eventually want to concretely handle these types. We may end up writing a huge AppError that all of these errors get injected into.

There’s one major, fatal flaw with this approach. While it composes very nicely, it doesn’t decompose at all! There’s no way to catch a single case and ensure that it’s handled. The machinery that prisms give us don’t allow us to separate out a single constraint, so we can’t pattern match on a single error.

Once again, our types become ever larger, with all of the problems that entails.

Generics to the rescue!

What we really want is:

In PureScript or OCaml, you can use open variant types to do this flawlessly. Haskell doesn’t have open variants, and the attempts to mock them end up quite clumsy to use in practice.

I’m happy to say that the entire job is handled quite nicely with the amazing generic-lens package. I created a gist that demonstrates their usage, but the magic comes down to this simple fact: there’s an instance of the prismatic AsType class for Either, which allows you to “pluck” a constraint off. This satisfies all of the things I wanted in my list, and we can consider representing errors mostly solved.

Womp Womp (edit: 2020-06-02)

Turns out, the above approach was premature. generic-lens only handles two types - it doesn’t “deep search” the Either, so this approach doesn’t work as well as I wanted to.

Fortunately, the technique I document in the Plucking Constraints post does work quite well, and it doesn’t even require lens knowledge! I wrote the plucky to demonstrate the technique for errors specifically.

As far as I know, this is the best approach in Haskell for type-safe errors.

Mostly?

Well, ExceptT e IO a still imposes a significant runtime performance hit, and asynchronous exceptions aren’t considered here. A bifunctor IO type like newtype BIO err a = BIO (IO a) which carries the type class constraints of the errors it contains is promising, but I haven’t been able to write a satisfying interface to this yet.

I also haven’t used this technique in a large codebase yet, and I don’t know how it scales. And the technique does require you to be comfortable with lens, which is a fairly high bar for training new folks on. I’m sure that API improvements could be made to make this style more accessible and remove some of the lens knowledge prerequisites.