Error monads for fun and profit
Last time, we corrected the security flaws in our simple Happstack.State demo program, but were left with some stinky error propagation logic. In particular:
- Using
Maybe
to carry error information, instead of using it to carry the result of a successful computation, violating the usual convention with its use. - Unwieldy tangles of conditionals to check for each possible error, obfuscating the normal path of execution.
Neither of these is insurmountable. For the first, Haskell already provides a type for results that may contain detailed error information: Either a b
. As you might guess, a value of that type is either something of type a or something of type b. By convention, a
is the error type and b
is the result type. The mnemonic is that the right type is what you get if everything goes right.
In fact, our previous code used Either
to return either an error message or a meaningful result from hashPasswordFor
:
hashPasswordFor :: MonadReader UserDirectory m => String -> String -> m (Either UserError PasswordHash)
hashPasswordFor name pass = do
UserDirectory dir <- ask
return $ case M.lookup name dir of
Nothing -> Left NoSuchUser
Just user -> Right $ hashPassword (pwSalt $ usPassword user) pass
If successful, this function returns a PasswordHash
via Either
‘s Right
type constructor. If the user couldn’t be found, it returns a UserError
via Either
‘s Left
type constructor.
You might think that we could use Either UserError
as a monad in much the same way we could use Maybe
as a monad: executing a series of computations until the first error. Sadly, Either a
isn’t defined to be a monad, so this doesn’t work.
Fortunately, the Monad Transformer Library has the next best thing: the ErrorT
monadic transform. In a nutshell, ErrorT
lets us transform any monad into something that adds error propagation. Specifically, any computation within the transformed monad can throw an error, skipping the remaining computations; the code using the transformed monad can then get an Either a b
out of it with either the result of successful computation (of type b
), or an error (of type a
).
If this sounds a lot like try/catch-style exception handling, that’s sort of the idea. And in case you cleverly scrolled down to the Haskell section of that Wikipedia page to see that Haskell has some support for this via the IO
monad, that’s true, but ErrorT
is a lot more powerful, not the least of which because there’s no need to use the IO
monad at all.
This might be clearer in an example. To use ErrorT
, we merely have to declare whatever error type we wish to use as an instance of the typeclass Error
, like so:
instance Error UserError
Looking up a user in the user directory is something our code does all the time, and each time we run the risk of failure if the user we’re looking for doesn’t exist. Let’s make a lookupUser
function that tries to get the UserInfo
for a user, or throws a UserError
if it failed:
lookupUser :: Monad m => String -> UserDirectory -> ErrorT UserError m UserInfo
lookupUser name (UserDirectory dir) = maybe (throwError NoSuchUser) return $ M.lookup name dir
Let’s unpack that a bit. The return type is Monad m => ErrorT UserError m UserInfo
. UserError
is the type of errors that could get thrown, and UserInfo
is the type of a successful result. m
is a type variable for the monad that ErrorT
is transforming; here, we don’t care what kind of monad m
is, as long as it’s a monad. The function just does a lookup in the Map
. If the result of the lookup is Just something
, that something
is return
ed (i.e., wrapped in) the monad. Otherwise, if the result of the lookup is Nothing
, we throw NoSuchUser
, which is of type UserError
.
Now let’s rewrite hashPasswordFor
to make use of lookupUser
:
hashPasswordFor :: MonadReader UserDirectory m => String -> String -> m (Either UserError PasswordHash)
hashPasswordFor name pass = runErrorT $ do
dir <- ask
user <- lookupUser name dir
return $ hashPassword (pwSalt $ usPassword user) pass
The main body of the function is free to ignore errors — there’s no more conditional check to see if the user lookup failed. Note, though, that the do
block that specifies the monadic computation is now an argument to runErrorT
. runErrorT
has a type signature of:
runErrorT :: ErrorT e m a -> m (Either e a)
As you can see from the type signature, it takes a computation in an ErrorT
-produced monad and converts it back into the original monad of type m
, with the result inside m
an Either e a
. In other words, it converts a computation where we can throw errors into one that returns Either
an error or the computation result.
You might wonder why we don’t just propagate errors out of hashPasswordFor
using ErrorT
like we did for lookupUser
, like this:
-- This doesn't work!
hashPasswordFor :: MonadReader UserDirectory m => String -> String -> ErrorT UserError m PasswordHash
hashPasswordFor name pass = do
dir <- ask
user <- lookupUser name dir
return $ hashPassword (pwSalt $ usPassword user) pass
There’s a very pragmatic reason why not: it doesn’t work. Recall that hashPasswordFor
is used to generate the HashPasswordFor
query operation in our MACID store. Happstack.State’s template magic crashes and burns if we try to return a computation involving ErrorT
:
Users.hs:1:0:
Exception when trying to run compile-time code:
Unexpected method type: Control.Monad.Error.ErrorT Users.UserError m_0 Users.PasswordHash
Code: mkMethods
'UserDirectory
['addUser, 'hashPasswordFor, 'authenticateUser, 'listUsers]
This is unfortunate, since we’re ultimately trying to use HashPasswordFor
and AuthenticateUser
— each of which can fail — in our implementation of loginUser
, and our whole goal is to wait until the very end to convert the result of the computation into an Either
. The workaround is to do the opposite of runErrorT
after we invoke HashPasswordFor
, converting the m (Either UserError PasswordHash)
back into a ErrorT UserError m PasswordHash
. Luckily, it’s pretty straightforward:
rethrowError :: (Error e, Monad m) => Either e a -> ErrorT e m a
rethrowError (Left error) = throwError error
rethrowError (Right result) = return result
Now we just need to feed the result of HashPasswordFor
and AuthenticateUser
into rethrowError
inside loginUser
:
loginUser :: MonadIO m => String -> String -> m (Either UserError ())
loginUser name pass = runErrorT $ do
passHash <- rethrowError =<< (query $ HashPasswordFor name pass)
now <- liftIO getClockTime
rethrowError =<< (update $ AuthenticateUser name passHash now)
Aside from the minor hassle of needing to use rethrowError
, this works quite nicely. Any UserError
that gets thrown, regardless of where it happens, get caught by runErrorT
and converted into Either UserError ()
for the result of the monadic computation. The code inside the do
block doesn’t have to worry about error checking; ErrorT
handles that for us.
hashPasswordFor
was a trivial example, but remember this ugly nastiness from the previous post?
authenticateUser :: MonadState UserDirectory m => String -> PasswordHash -> ClockTime -> m (Maybe UserError)
authenticateUser name passHash when = do
UserDirectory dir <- get
case M.lookup name dir of
Nothing -> return $ Just NoSuchUser
Just user -> if isLocked when user
then return $ fmap AccountLocked $ usLocked user
else if passHash == usPassword user
then do put $ UserDirectory $ M.insert name (unlockUser user) dir
return Nothing
else do put $ UserDirectory $ M.insert name (failUser when user) dir
return $ Just PasswordMismatch
Here’s what a ErrorT
magic lets us replace that with:
authenticateUser :: MonadState UserDirectory m =>
String -> PasswordHash -> ClockTime -> m (Either UserError ())
authenticateUser name passHash when = runErrorT $ do
dir <- get
user <- lookupUser name dir
checkUnlocked when user
if passHash == usPassword user
then do insertUser name (unlockUser user)
return ()
else do insertUser name (failUser when user)
throwError PasswordMismatch
Suddenly it’s much easier to see what the code’s supposed to do! Goodbye deep nesting of conditionals; if not for the fact that we need to update the data store differently based on whether we see a password mismatch, we wouldn’t even need the one still there.
Just for completeness’s sake, here’s checkUnlocked
, which replaces isLocked
from the previous code:
checkUnlocked :: Monad m => ClockTime -> UserInfo -> ErrorT UserError m ()
checkUnlocked asOf user = case usLocked user of
Just until -> when (asOf < until) (throwError $ AccountLocked until)
Nothing -> return ()
Technically we could’ve used maybe
instead of pattern-matching to turn checkUnlocked
into a one-liner like isLocked
was, but I think the code becomes too difficult to read in that case, which defeats the whole rationale behind using ErrorT
throughout our code in the first place.
As always, here’s the complete program with these changes. The code behaves the exact same way as the previous version, but the implementation is now much easier on the eyes.
Let this be a lesson to you: it’s often said that most programming problems can be simplified by adding another level of indirection. In Haskell, I suspect the equivalent is adding another monad. Monads are a little tricky to get your head around at first, but you can do some neat things with them.