Lightweight Checked Exceptions in Haskell
Friday, 31 July 2015, by Edsko de Vries, Adam Gundry.
Filed under coding.
Consider this function from the http-conduit library:
-- | Download the specified URL (..)
--
-- This function will 'throwIO' an 'HttpException' for (..)
simpleHttp :: MonadIO m => String -> m ByteString
Notice that part of the semantics of this function—that it may throw an HttpException
—is encoded in a comment, which the compiler cannot check. This is because Haskell’s notion of exceptions offers no mechanism for advertising to the user the fact that a function might throw an exception.
Michael Snoyman discusses some solutions to this problem, as well as some common anti-patterns, in his blog post Exceptions Best Practices. However, wouldn’t it be much nicer if we could simply express in the type that simpleHttp
may throw a HttpException
? In this blog post I will propose a very lightweight scheme to do precisely that.
Throwing checked exceptions
Let’s introduce a type class for “checked exceptions” (à la Java):
-- | Checked exceptions
class Throws e where
throwChecked :: e -> IO a
This looks simple enough, but here’s the rub: this will be a type class without any instances.
Once we have introduced Throws
we can write something like
simpleHttp :: (MonadIO m, Throws HttpException) => String -> m ByteString
simpleHttp _ = liftIO $ throwChecked HttpException
And, unless we explicitly catch this exception, this annotation will now be propagated to every use site of simpleHttp
:
useSimpleHttp :: Throws HttpException => IO ByteString
useSimpleHttp = simpleHttp "http://www.example.com"
Type annotations
There’s something a little peculiar about a type class constraint such as Throws HttpException
: normally ghc
will refuse to add a type class constraint for a known (constant) type. If you were to write
foo = throwChecked $ userError "Uhoh"
ghc
will complain bitterly that
No instance for (Throws IOError)
arising from a use of ‘throwChecked’
In the expression: throwChecked
until you give the type annotation explicitly (you will need to enable the FlexibleContexts
language extension):
foo :: Throws IOError => IO a
foo = throwChecked $ userError "Uhoh"
I consider this a feature, not a bug of this approach: you are forced to explicitly declare the checked exceptions you throw.
Catching checked exceptions
In order to catch checked exceptions (and indeed, in order make throwChecked
actually do anything) we need to turn that throwChecked
into an honest-to-goodness throwIO
. We can do this by creating a type class instance at runtime (requires RankNTypes
and ScopedTypeVariables
):
-- | Rethrow checked exceptions as unchecked (regular) exceptions
rethrowUnchecked :: forall e a.
(Throws e => IO a) -> (Exception e => IO a)
rethrowUnchecked act = aux act throwIO
where
aux :: (Throws e => IO a) -> ((e -> IO a) -> IO a)
aux = unsafeCoerce
This method of creating type class instances at runtime is a well-known trick in the Haskell community, used for instance in Edward Kmett’s reflection package and Richard Eisenberg’s singletons package. Austin Sepp’s blogpost Reflecting values to types and back explains it in detail.
For us here the important thing is that rethrowUnchecked
basically replaces uses of throwChecked
with a regular throwIO
, and hence we can catch the resulting “unchecked” exception as usual:
-- | Catch a checked exception
--
-- This is the only way to discharge a 'Throws' type class constraint.
catchChecked :: Exception e => (Throws e => IO a) -> (e -> IO a) -> IO a
catchChecked = catch . rethrowUnchecked
-- | 'catchChecked' with the arguments reversed
handleChecked :: Exception e => (e -> IO a) -> (Throws e => IO a) -> IO a
handleChecked = flip catchChecked
Subclasses of exceptions
Suppose we had
readFile :: Throws IOException => FilePath -> IO String
then we can write a function to get a file either by reading a local file or by downloading it over HTTP:
get :: (Throws IOException, Throws HttpException)
=> String -> IO ByteString
get url = case removePrefix "file:" url of
Just path -> readFile path
Nothing -> simpleHttp url
removePrefix :: [a] -> [a] -> Maybe [a]
removePrefix = ..
Alternatively we can define a bespoke exception hierarchy and combine the two exceptions:
data SomeGetException = forall e. Exception e => SomeGetException e
wrapIO :: (Throws IOException => IO a)
-> (Throws SomeGetException => IO a)
wrapIO = handleChecked $ throwChecked . SomeGetException
wrapHttp :: (Throws HttpException => IO a)
-> (Throws SomeGetException => IO a)
wrapHttp = handleChecked $ throwChecked . SomeGetException
get :: Throws SomeGetException => String -> IO ByteString
get url = case removePrefix "file:" url of
Just path -> wrapIO $ readFile path
Nothing -> wrapHttp $ simpleHttp url
This kind of custom exception hierarchy is entirely standard; I just wanted to show it fits nicely into this approach to checked exceptions.
Caveat
There is one caveat to be aware of. Suppose we write
returnAction = return (simpleHttp "http://www.example.com")
Ideally we’d give this a type such as
returnAction :: IO (Throws HttpException => IO ByteString)
returnAction = return (simpleHttp "http://www.example.com")
But this requires impredicative types, which is still a no-go zone. Instead the type of returnAction
will be
returnAction :: Throws HttpException => IO (IO ByteString)
returnAction = return (simpleHttp "http://www.example.com")
which has the Throws
annotation on returnAction
itself; this means we can make the annotation disappear by adding an exception handler to returnAction
even though it’s never called (because returnAction
itself never throws any exception).
returnAction' :: IO (IO ByteString)
returnAction' = catchChecked returnAction neverActuallyCalled
where
neverActuallyCalled :: HttpException -> IO (IO ByteString)
neverActuallyCalled = undefined
This is somewhat unfortunate, but it occurs only infrequently and it’s not a huge problem in practice. If you do need to return actions that may throw exceptions, you can define a newtype wrapper:
newtype Action = Action (Throws HttpException => IO ByteString)
returnAction :: IO Action
returnAction = return (Action $ simpleHttp "http://www.example.com")
This is the approach we take in the hackage-security
library too; for instance see the definition of HttpClient
in the Repository.Remote
module.
Conclusions
Of course, a type such as
simpleHttp :: (MonadIO m, Throws HttpException) => String -> m ByteString
does not tell you that this function can only throw HttpException
s; it can still throw all kinds of unchecked exceptions, not least of which asynchronous exceptions. But that’s okay: it can still be incredibly useful to track some exceptions through your code.
So there you have it: checked exceptions in Haskell using one, singleton, type class Throws
with no instances, just two functions rethrowUnchecked
and catchChecked
, requiring only three non-controversial language extensions (RankNTypes
, ScopedTypeVariables
, and FlexibleContexts
), and without introducing a special new kind of monad (such as in the control-monad-exception package) and without complicated type level hacking as in the Checked Exception for Free blogpost.
Right now I’ve implemented this as a small module Checked.hs in the hackage-security library, but if people think this is a worthwhile approach perhaps it would be useful to release this as a separate tiny library.