Thursday, December 19, 2013

Lift error handling with lens-like syntax

One of the main deficiencies of transformers is that the MonadTrans class does not let you lift functions like catchError. The mtl library provides one solution to this problem, which is to type-class catchError and throwError using the MonadError type class.

That's not to say that transformers has no solution for lifting catchError and throwError; it's just really verbose. Each module provides a liftCatch function that you use to lift a catchError function from the base monad to the transformed monad.

For example, Control.Monad.Trans.State provides the following liftCatch function:

    :: (m (a, s) -> (e -> m (a, s)) -> m (a, s))
    -> StateT s m a -> (e -> StateT s m a) -> StateT s m a

If your monad transformer stack was:

myStack :: (Monad m, Error e) => StateT s (ErrorT e m)

... then you would apply liftCatch directly to catchError to get a catch function that worked at the StateT layer:

Control.Monad.Trans.State.liftCatch catchError
    :: (Monad m, Error e)
    => StateT s (ErrorT e m) a
    -> (e -> StateT s (ErrorT e m) a)
    -> StateT s (ErrorT e m) a

But what if you had a WriterT layer in between like this:

myStack :: (Monad m) => StateT s (WriterT w (ErrorT e m))

You'd have to use liftCatch from the Control.Monad.Trans.Writer module to further lift the catchError to work with this stack:

import Control.Monad.Trans.State as S
import Control.Monad.Trans.Writer as W

S.liftCatch (W.liftCatch catchError)
    :: (Monad m, Error e)
    => StateT s (WriterT w (ErrorT e m)) a
    -> (e -> StateT s (WriterT w (ErrorT e m)) a)
    -> StateT s (WriterT w (ErrorT e m)) a

The advantage of this solution is that type inference works extraordinarily well and it is Haskell98. The disadvantage of this solution is that it is very verbose.

So I will propose a less verbose solution that has a syntax resembling the lens library, although there will be no true lenses involved (I think). Define the following catching function:

 :: (Monad m, Error e)
 => ((ErrorT e m a -> (e -> ErrorT e m a) -> ErrorT e m a) -> r)
 -> r
catching k = k catchError

The k in the above function will be a series of composed liftCatch functions that we will apply to catchError. However, in the spirit of the lens library we will rename these liftCatch functions to be less verbose and more hip and sexy:

import qualified Control.Monad.Trans.Maybe  as M
import qualified Control.Monad.Trans.Reader as R
import qualified Control.Monad.Trans.State  as S
import qualified Control.Monad.Trans.Writer as W
import qualified Pipes.Lift                 as P

state  = S.liftCatch

writer = W.liftCatch

reader = R.liftCatch

maybe  = M.liftCatch

pipe   = P.liftCatchError

Now let's say that we have a monad transformer stack of type:

    :: (Monad m, Error e)
    => StateT s (MaybeT (ErrorT e m)) r

If I want to catch something at the outer StateT level, I would just write:

myStack = catching (state.maybe) action $ \e -> do
    -- The handler if `action` fails

If I add more layers to my monad transformer stack, all I have to do is point deeper to the stack by composing more references:

    :: (Monad m, Error e, Monoid w)
    => Pipe a b (StateT s (WriterT w (MaybeT m))) r
myStack = catching (pipe.state.writer.maybe) action $ \e -> ...

Also, these references are first-class Haskell values, so you can bundle them and manipulate them with your favorite functional tools:

myStack = do
    let total = pipe.state.writer.maybe
    catching total action1 $ \e -> ...
    catching total action2 $ \e -> ...

This approach has a few advantages over the traditional MonadError approach:

  • You get improved type inference
  • You get type errors earlier in development. With MonadError the compiler will not detect an error until you try to run your monad transformer stack.
  • You get better type errors. MonadError errors will arise at a distance where you call runErrorT even though the logical error is probably at the site of the catchError function.
  • Functional references are first class and type classes are not

However, we don't want to lift just catchError. There are many other functions that transformers can lift such as local, listen, and callCC. An interesting question would be whether this is some elegant abstraction that packages all these lifting operations into a simple type in the same way that lenses package getters, setters, traversals, maps, and zooms into a single abstraction. If there were, then we could reuse the same references for catching, listening, and other operations that are otherwise difficult to lift:

listening (state.error) m

passing (maybe.writer) m

catching (writer.maybe) m $ \e -> ...

I have no idea if such an elegant abstraction exists, though, which is why I'm writing this post to solicit ideas.

No comments:

Post a Comment