The content below is still correct, but has been absorbed into the more comprehensive safe exception handling tutorial document instead. I recommend reading that, which provides more information and more up-to-date library references.
Over the years, I've written a number of different documents,
tutorials, comments, and libraries on how to do proper exception
handling in Haskell. Most of this has culminated in the creation of
safe-exceptions
library, which I strongly recommend everyone use. That library
contains a full tutorial which explains many of the more subtle
points of exceptions, and describes how exceptions are handled by
that library.
Overall, I consider that library uncontroversial, simply
addressing the reality of exceptions in GHC today. This blog post
is the opinionated part: how I recommend you use exceptions in
Haskell, and how to structure your code around them. There are
dissenting opinions, which is why this is an opinion blog post
instead of library documentation. However, in my experience, these
approaches are the best way to make your code robust.
This blog post is also part of the FP Complete Haskell Syllabus and part of
our Haskell training.
The IO contract
A commonly stated position in the Haskell community around
exceptions goes something like "all exceptions should be explicit
at the type level, and async exceptions are terrible." We can argue
as much as we want about this point in a theoretical sense.
However, practically, it is irrelevant, because GHC has already
chosen a stance on this: it supports async exceptions, and all code
that runs in IO
can have exceptions of any type
which is an instance of Exception
.
I'd go a step further, and say not only are we stuck with GHC's
decisions, but GHC's decisions are a great point in the design
space. I'll explain that below.
So take as a given: any code in IO
can throw a
runtime exception, and any thread can be killed at any time by an
asynchronous exception.
Let's identify a few anti-patterns in Haskell exception
handling, and then move on to recommended practices.
The bad
ExceptT IO anti-pattern
A common (bad) design pattern I see is something like the
following:
myFunction :: String -> ExceptT MyException IO Int
There are (at least) three problems with this:
- It's non-composable. If someone else has a separate
exception type
HisException
, these two functions do
not easily compose.
- It gives an implication which is almost certainly false,
namely: the only exception that can be thrown from this function is
MyException
. Almost any IO
code in there
will have the ability to throw some other type of exception, and
additionally, almost any async exception can be thrown even if no
synchronous exception is possible.
- You haven't limited the possibility of exceptions, you've only
added one extra avenue by which an exception can be thrown.
myFunction
can now either throwE
or
liftIO . throwIO
.
It is almost always wrong to wrap an ExceptT
,
EitherT
, or ErrorT
around an
IO
-based transformer stack.
Separate issue: it's also almost always a bad idea to have such
a concrete transformer stack used in a public-facing API. It's
usually better to express a function in terms of typeclass
requirements, using mtl typeclasses as necessary.
A similar pattern is
myFunction :: String -> ExceptT Text IO Int
This is usually done with the idea that in the future the error
type will be changed from Text
to something like
MyException
. However, Text
may end up
sticking around forever because it helps avoid the composition
problems of a real data type. However that leads to expressing
useful error data types as unstructured Text
.
Generally the solution to the ExceptT IO
anti-pattern is to return an Either
from more
functions and throw an exception for uncommon errors. Note that
returning Either
from ExceptT IO
means
there are now 3 distinct sources of errors in just one
function.
Please note that using ExceptT, etc with a non-IO base monad
(for example with pure code) is a perfectly fine pattern.
Mask-them-all anti-pattern
This anti-pattern goes like this: remembering to deal with async
exceptions everywhere is hard, so I'll just mask them all.
Every time you do this, 17 kittens are mauled to death by the
loch ness monster.
Async exceptions may be annoying, but they are vital to keeping
a system functioning correctly. The timeout
function
uses them to great benefit. The Warp webserver bases all of its
slowloris protection on async exceptions. The cancel function from
the async package will hang indefinitely if async exceptions are
masked. Et cetera et cetera.
Are async exceptions difficult to work with? Sometimes, yes.
Deal with it anyway. Best practices include:
- Use the bracket pattern wherever possible.
- Use the safe-exceptions package.
- If you have truly complex flow of control and non-linear
scoping of resources, use the resourcet package.
The good
MonadThrow
Consider the following function:
foo <- lookup "foo" m
bar <- lookup "bar" m
baz <- lookup "baz" m
f foo bar baz
If this function returns Nothing
, we have no idea
why. It could be because:
- "foo" wasn't in the map.
- "bar" wasn't in the map.
- "baz" wasn't in the map.
f
returned Nothing
.
The problem is that we've thrown away a lot of information by
having our functions return Maybe
. Instead, wouldn't
it be nice if the types of our functions were:
lookup :: Eq k => k -> [(k, v)] -> Either (KeyNotFound k) v
f :: SomeVal -> SomeVal -> SomeVal -> Either F'sExceptionType F'sResult
The problem is that these types don't unify. Also, it's commonly
the case that we really don't need to know about why a
lookup failed, we just need to deal with it. For those cases,
Maybe
is better.
The solution to this is the MonadThrow
typeclass
from the exceptions package. With that, we would write the type
signatures as:
lookup :: (MonadThrow m, Eq k) => k -> [(k, v)] -> m v
f :: MonadThrow m => SomeVal -> SomeVal -> SomeVal -> m F'sResult
Versus the Either
signature, we lose some
information, namely the type of exception that could be thrown.
However, we gain composability and unification with
Maybe
(as well as many other useful instances of
MonadThrow
, like IO
).
The MonadThrow
typeclass is a tradeoff, but it's a
well thought out tradeoff, and usually the right one. It's also in
line with Haskell's runtime exception system, which does not
capture the types of exceptions that can be thrown.
The following type signature is overly restrictive:
foo :: Int -> IO String
This can always be generalized with a usage of
liftIO
to:
foo :: MonadIO m => Int -> m String
This allows our function to easily work with any transformer on
top of IO
. However, given how easy it is to apply
liftIO
, it's not too horrible a restriction. However,
consider this function:
bar :: FilePath -> (Handle -> IO a) -> IO a
If you want your inner function to live in a transformer on top
of IO
, you'll find it difficult to make it work. It
can be done with lifted-base
, but it's non-trivial.
Instead, it's much better to express this function in terms of
functions from either the safe-exceptions library, and get the
following more generalized type signatures:
bar :: (MonadIO m, MonadMask m) => FilePath -> (Handle -> m a) -> m a
This doesn't just apply to exception handling, but also to
dealing with things like forking threads. Another thing to consider
in these cases is to use the Acquire
type from
resourcet.
Custom exception types
The following is bad practice:
foo = do
if x then return y else error "something bad happened"
The problem is the usage of arbitrary string-based error
messages. This makes it difficult to handle this exceptional case
directly in a higher level in the call stack. Instead, despite the
boilerplate overhead involved, it's best to define a custom
exception type:
data SomethingBad = SomethingBad
deriving Typeable
instance Show SomethingBad where
show SomethingBad = "something bad happened"
instance Exception SomethingBad
foo = do
if x then return y else throwM SomethingBad
Now it's trivial to catch the SomethingBad
exception type at a higher level. Additionally, throwM
gives better exception ordering guarantees than error
,
which creates an exception in a pure value that needs to be
evaluated before it's thrown.
One sore point is that some people strongly oppose a
Show
instance like this. This is an open discussion,
but for now I believe we need to make the tradeoff at this point in
the spectrum. The displayException
method in the
Exception
typeclass may allow for a better resolution
to this point in the future.
Why GHC's
point in the design space is great
This section is adapted from
a comment I made on Reddit in 2014.
I don't believe there is a better solution to sync
exceptions, actually. That's because most of the time I see people
complaining about IO
throwing exceptions, what they
really mean is "this specific exception just bit me, why
isn't this exception explicit in the type signature?" To clarify my
point further:
- There are virtually 0
IO
actions that can't fail
for some reason.
- If every
IO
action returned a IO (Either
UniqueExceptionType a)
, the programming model would become
incredibly tedious. * Also, it would become very likely that
when a
is ()
, it would be easy to forget
to check the return type to see if an exception occurred.
- If instead every
IO
action returned IO
(Either SomeException a)
, we'd at least not have to deal
with wrangling different exception types, and could use
ErrorT
to make our code simpler, but...
- Then we've just reinvented exactly what
IO
does
today, only less efficiently!
My belief is that people are simply ignoring the reality of the
situation: the contract for IO
implicitly includes
"this action may also fail." And I mean in every single case. Built
in, runtime exceptions hide that in the type, but you need to be
aware of it. Runtime exceptions also happen to be far more
efficient than using ErrorT
everywhere.
And as much as some people complain that exceptions are
difficult to handle correctly, I highly doubt ErrorT
or anything else would be easier to work with, we'd just be trading
in a well-developed, mostly-understood system for a system we think
we understand.
Concrete example: readLine
After a request on
Twitter, I decided to add a little section here showing a
pragmatic example: how should we implement a function to read a
line from stdin and parse it? Let's start with a simpler question:
how about just parsing an input String
? We'd like to
have a meaningful exception that tells us which value didn't parse
(the input String
) and what type we tried to parse it
as. We'll implement this as a readM
function:
#!/usr/bin/env stack
import Control.Exception.Safe (Exception, MonadThrow, SomeException, throwM)
import Data.Typeable (TypeRep, Typeable, typeRep)
import Text.Read (readMaybe)
data ReadException = ReadException String TypeRep
deriving (Typeable)
instance Show ReadException where
show (ReadException s typ) = concat
[ "Unable to parse as "
, show typ
, ": "
, show s
]
instance Exception ReadException
readM :: (MonadThrow m, Read a, Typeable a) => String -> m a
readM s = res
where
res =
case readMaybe s of
Just x -> return x
Nothing -> throwM $ ReadException s (typeRep res)
main :: IO ()
main = do
print (readM "hello" :: Either SomeException Int)
print (readM "5" :: Either SomeException Int)
print (readM "5" :: Either SomeException Bool)
res1 <- readM "6"
print (res1 :: Int)
res2 <- readM "not an int"
print (res2 :: Int)
This meets our criteria of having a generalizable function to
multiple monads and useful exceptions. If we now make a
readLine
function that reads from stdin, we have
essentially two different choices of type signature:
readLine1 :: (MonadIO m, MonadThrow n, Read a, Typeable
a) => m (n a)
: With this signature, we're saying "the
case of failure is pretty common, and we therefore don't want to
mix it in with the same monad that's handling IO
side-effects.
readLine2 :: (MonadIO m, MonadThrow m, Read a, Typeable
a) => m a
: By contrast, we can actually combine the two
different monads (IO
side-effects and failure) into
one layer. This is implicitly saying "failure is a case we don't
usually want to deal with, and therefore the user should explicitly
use tryAny
or similar to extract such failures. That
said, in practice, there's not much point in having both
MonadIO
and MonadThrow
, since you can
just use liftIO
to combine them (as you'll see in a
moment). So instead, our signature is readLine2 :: (MonadIO
m, Read a, Typeable a) => m a
.
Which one of these you choose in practice really does depend on
your personal preferences. The former is much more explicit about
the failure. However, in general I'd steer away from it, since -
like ExceptT
over IO
- it gives the false
impression that all failures are captured by the inner value.
Still, I thought it was worth demonstrating both options:
#!/usr/bin/env stack
import Control.Exception.Safe (Exception, MonadThrow, SomeException, throwM)
import Control.Monad (join)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Typeable (TypeRep, Typeable, typeRep)
import Text.Read (readMaybe)
data ReadException = ReadException String TypeRep
deriving (Typeable)
instance Show ReadException where
show (ReadException s typ) = concat
[ "Unable to parse as "
, show typ
, ": "
, show s
]
instance Exception ReadException
readM :: (MonadThrow m, Read a, Typeable a) => String -> m a
readM s = res
where
res =
case readMaybe s of
Just x -> return x
Nothing -> throwM $ ReadException s (typeRep res)
readLine1 :: (MonadIO m, MonadThrow n, Read a, Typeable a) => m (n a)
readLine1 = fmap readM (liftIO getLine)
readLine2 :: (MonadIO m, Read a, Typeable a) => m a
readLine2 = liftIO (join readLine1)
main :: IO ()
main = do
putStrLn "Enter an Int (non-runtime exception)"
res1 <- readLine1
print (res1 :: Either SomeException Int)
putStrLn "Enter an Int (runtime exception)"
res2 <- readLine2
print (res2 :: Int)
See also
Like what you learned here? Please check out the rest of our
Haskell Syllabus or learn about
FP Complete training.
Subscribe to our blog via email
Email subscriptions come from our Atom feed and are handled by Blogtrottr. You will only receive notifications of blog posts, and can unsubscribe any time.
Do you like this blog post and need help with Next Generation Software Engineering, Platform Engineering or Blockchain & Smart Contracts? Contact us.