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 anyIO
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 eitherthrowE
orliftIO . 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
returnedNothing
.
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.
Transformers
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 aIO (Either UniqueExceptionType a)
, the programming model would become incredibly tedious. * Also, it would become very likely that whena
is()
, it would be easy to forget to check the return type to see if an exception occurred. - If instead every
IO
action returnedIO (Either SomeException a)
, we'd at least not have to deal with wrangling different exception types, and could useErrorT
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 -- stack --resolver lts-7.8 runghc --package safe-exceptions {-# OPTIONS_GHC -Wall -Werror #-} 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) -- Also works in plain IO res1 <- readM "6" print (res1 :: Int) res2 <- readM "not an int" print (res2 :: Int) -- will never get called
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 handlingIO
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 usetryAny
or similar to extract such failures. That said, in practice, there's not much point in having bothMonadIO
andMonadThrow
, since you can just useliftIO
to combine them (as you'll see in a moment). So instead, our signature isreadLine2 :: (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 -- stack --resolver lts-7.8 runghc --package safe-exceptions {-# OPTIONS_GHC -Wall -Werror #-} 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) -- Without the usage of liftIO here, we'd need both MonadIO and -- MonadThrow constraints. 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.