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
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
There’s only one way that
lookup could fail.
So we just use
Maybe a is just like using
Either () a – there’s only one possible
Left () value, and there’s only one possible
(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).
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
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
There’s no way that a call to
foo will result in
foo can’t even do
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
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
It’s obvious in this case, but in larger functions and codebases, it won’t be so clear.
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.
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
And expressions which you’d really want to be equal, aren’t –
x :: Either (HeadError + LookupError) Int y :: Either (LookupError + HeadError) Int
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.
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
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.
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.
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
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.
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.