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:

  1. 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.
  2. 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 returned (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:

    Exception when trying to run compile-time code:
      Unexpected method type: Control.Monad.Error.ErrorT Users.UserError m_0 Users.PasswordHash
      Code: mkMethods
              ['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.

Comments Off