Home > Back-end >  How to use ExceptT to replace lots of IO (Either a b)
How to use ExceptT to replace lots of IO (Either a b)

Time:09-28

I have a function which connects to a database, then runs a query. Each of these steps results in IO (Either SomeErrorType SomeResultType).

One of the things I have really liked about using Either and similar monads in learning Haskell has been the ability to use the monad functions like >>= and combinators like mapLeft to streamline a lot of the handling of expected error states.

My expectation here from reading blog posts, the Control.Monad.Trans documentation, and other answers on SO is that I have to somehow use transformers / lift to move from the IO context to the Either context.

This answer in particular is really good, but I'm struggling to apply it to my own case.

A simpler example of my code:

simpleVersion :: Integer -> Config -> IO ()
simpleVersion id c = 
  connect c >>= \case 
      (Left e)     -> printErrorAndExit e
      (Right conn) -> (run . query id $ conn)
              >>= \case 
                    (Left e)  -> printErrorAndExit e
                    (Right r) -> print r
                                   >> release conn

My problem is that (a) I'm not really understanding the mechanics of how ExceptT gets me to a similar place to the mapLeft handleErrors $ eitherErrorOrResult >>= someOtherErrorOrResult >>= print world; (b) I'm not sure how to ensure that the connection is always released in the nicest way (even in my simple example above), although I suppose I would use the bracket pattern.

I'm sure every (relatively) new Haskeller says this but I still really don't understand monad transformers and everything I read (except aforelinked SO answer) is too opaque for me (yet).

How can I transform the code above into something which removes all this nesting and error handling?

CodePudding user response:

I think it's very enlightening to look at the source for the Monad instance of ExceptT:

newtype ExceptT e m a = ExceptT (m (Either e a))

instance (Monad m) => Monad (ExceptT e m) where
    return a = ExceptT $ return (Right a)
    m >>= k = ExceptT $ do
        a <- runExceptT m
        case a of
            Left e -> return (Left e)
            Right x -> runExceptT (k x)

If you ignore the newtype wrapping and unwrapping, it becomes even simpler:

m >>= k = do
    a <- m
    case a of
        Left e -> return (Left e)
        Right x -> k x

Or, as you seem to prefer not using do:

m >>= k = m >>= \a -> case a of
    Left e -> return (Left e)
    Right x -> k x

Does that chunk of code look familiar to you? The only difference between that and your code is that you write printErrorAndExit instead of return . Left! So, let's move that printErrorAndExit out to the top-level, and simply be happy remembering the error for now and not printing it.

simpleVersion :: Integer -> Config -> IO (Either Err ())
simpleVersion id c = connect c >>= \case (Left e)     -> return (Left e)
                                         (Right conn) -> (run . query id $ conn)
                                                          >>= \case (Left e)  -> return (Left e)
                                                                    (Right r) -> Right <$> (print r
                                                          >> release conn)

Besides the change I called out, you also have to stick a Right <$> at the end to convert from an IO () action to an IO (Either Err ()) action. (More on this momentarily.)

Okay, let's try substituting our ExceptT bind from above for the IO bind. I'll add a ' to distinguish the ExceptT versions from the IO versions (e.g. >>=' :: IO (Either Err a) -> (a -> IO (Either Err b)) -> IO (Either Err b)).

simpleVersion id c = connect c >>=' \conn -> (run . query id $ conn)
                                             >>=' \r -> Right <$> (print r
                                             >> {- IO >>! -} release conn)

That's already an improvement, and some whitespace changes make it even better. I'll also include a do version.

simpleVersion id c =
    connect c >>=' \conn ->
    (run . query id $ conn) >>=' \r ->
    Right <$> (print r >> release conn)

simpleVersion id c = do
    conn <- connect c
    r <- run . query id $ conn
    Right <$> (print r >> release conn)

To me, that looks pretty clean! Of course, in main, you'll still want to printErrorAndExit, as in:

main = do
    v <- runExceptT (simpleVersion 0 defaultConfig)
    either printErrorAndExit pure v

Now, about that Right <$> (...)... I said I wanted to convert from IO a to IO (Either Err a). Well, this kind of thing is why the MonadTrans class exists; let's look at its implementation for ExceptT:

instance MonadTrans (ExceptT e) where
    lift = ExceptT . liftM Right

Well, liftM and (<$>) are the same function with different names. So if we ignore the newtype wrapping and unwrapping, we get

lift m = Right <$> m

! So:

simpleVersion id c = do
    conn <- connect c
    r <- run . query id $ conn
    lift (print r >> release conn)

You could also choose to use liftIO if you like. The difference is that lift always lifts a monadic action up through exactly one transformer, but works for any pair of wrapped type and transformer type; while liftIO lifts an IO action up through as many transformers as necessary for your monad transformer stack, but only works for IO actions.

Of course, so far we've elided all the newtype wrapping and unwrapping. For simpleVersion to be as beautiful as it is in our last example here, you'd need to change connect and run to include those wrappers as appropriate.

  • Related