4

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?

Will Ness
  • 70,110
  • 9
  • 98
  • 181
GTF
  • 8,031
  • 5
  • 36
  • 59
  • 1
    Related [question](https://stackoverflow.com/questions/46831992/the-usage-of-monad-transformers/46833365#46833365) – freestyle Sep 25 '21 at 16:33
  • 1
    You might also like [How do I deal with many levels of indentation?](https://stackoverflow.com/q/33005903/791604). – Daniel Wagner Sep 25 '21 at 16:44
  • 1
    Also related, but about `MaybeT` instead of `ExceptT`: https://stackoverflow.com/questions/32579133/simplest-non-trivial-monad-transformer-example-for-dummies-iomaybe One note about `bracket` from base: it works on plain `IO` actions instead of `IO` actions "decorated" with transformers. One possible approach would be using `ExceptT` to avoid the nested `case`s, then using `runExceptT` to get back to an `IO` action, and wrapping that resulting action with `bracket`. – danidiaz Sep 25 '21 at 16:58

2 Answers2

7

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.

Daniel Wagner
  • 145,880
  • 9
  • 220
  • 380
  • 1
    It took me some time to fully get to grips with this but thank you for a really excellent answer. A quick follow-up question: how would you incorporate something like `bracket` to ensure the connection is always closed? Do I need to break the `ExceptT` which covers the `connect` call out into `IO (Either ConnectionError Connection)` so that I can handle the left case? – GTF Sep 27 '21 at 13:24
  • @GTF Got it in one! – Daniel Wagner Sep 27 '21 at 13:56
  • And something like https://hackage.haskell.org/package/errors-ext? It feels like there must be a common way of doing bracket-like things over ExceptT? – GTF Sep 27 '21 at 17:28
  • @GTF `MonadBaseControl` is a little controversial, but it will probably get you where you need to go for simple cases like this. – Daniel Wagner Sep 27 '21 at 17:35
  • Thanks for the pointer, I'll read Snoyman's tutorial on it (https://www.yesodweb.com/book/monad-control) – GTF Sep 27 '21 at 17:41
  • @GTF ...just to be clear: the link you posted is a `MonadBaseControl` one. That's why I talked about it; not because I endorse that approach myself (I don't). – Daniel Wagner Sep 27 '21 at 18:14
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/237573/discussion-between-gtf-and-daniel-wagner). – GTF Sep 27 '21 at 20:00
1

I totally agree with everything that @Daniel Wagner wrote. But there is another option how to organize work with IO (Either err a).

There is function try :: Exception e => IO a -> IO (Either e a), can we implement inverted version of it (untry :: Exception e => IO (Either e a) -> IO a)? Let's do that:

untry :: Exception e => IO (Either e a) -> IO a
untry action = action >>= either throwIO pure

Similar function is in package unliftio named fromEitherM.

Now we can redesign your example:

simpleVersion :: Integer -> Config -> IO ()
simpleVersion id c = do
    conn <- untry . connect $ c
    r <- untry . run . query id $ conn
    print r
    release conn

For this trick to work, the error type needs to be instance of the Exception class.

printErrorAndExit is not used here because it should be done at the top level (main or near it). And there is possible to handle errors with functions such as catch, try.

The only problem left is that the connection can be not released if an error occurs between opening and closing it. Let's fix that with bracket:

simpleVersion :: Integer -> Config -> IO ()
simpleVersion id c = bracket (untry $ connect c) release $ \conn -> do
    r <- untry $ run $ query id conn
    print r

You can write helper function for create connection:

withConn :: Config -> (Connection -> IO a) -> IO a
withConn c = bracket (untry $ connect c) release

and than:

simpleVersion id = flip withConn $ \conn -> do
    r <- untry $ run $ query id conn
    print r

Than you can thinking about to use ReaderT Connection IO for implicitly provide the connection to your queries. After that you code can looks like:

simpleVersion id conf = withConn conf $ do
    r <- run $ query id
    print r
freestyle
  • 3,692
  • 11
  • 21