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.