How to refactor this code combining IO and Either?

Hi all, I’ve been working on a side-project (a small utility for building DJ playlists) for a couple of months while re-learning a bit of Haskell.

There’s a library of pure functions and a CLI (built with optparse-applicative) that does some sfuff using those functions.

In the CLI code, I ended up with a bunch of function with this shape:

handleMakePlaylist :: MakePlaylistOptions -> IO ()
handleMakePlaylist opts =
  do
    lib <- readLibFromFile_ opts.libraryPath
    case lib of
      Left e -> print e
      Right lib' -> do
        plist <- mkPlaylist lib' opts.firstTrack opts.maxTracks
        case plist of
          Left e -> print e
          Right plist' -> putStr $ renderResolvedPlaylist plist'

I’m pretty sure this can be simplified/refactored but I’m not sure what’s the best practice here. Looks like the problem is combining Either and IO.

I’m trying to keep LLM use to a bare minimum since this is a learning project, but… well, I did eventually ask, and it told me to use ExceptT. Then I did a quick google search and found some (old) post of people saying they “hate” monad transformers.

So I thought perhaps I should ask people here: what’s the underlying abstraction there and what’s the best practice to handle it? Any blogs, papers, book chapters etc, welcome! :slight_smile:

Hello! Let me see if I can help.

You’ve got two functions returning Show e => IO (Either e x) values, it seems: readLibFromFile_, mkPlaylist. The good news is,

newtype ExceptT e m x = ExceptT {runExceptT :: m (Either e x)}

so ExceptT e IO gives you something isomorphic to what you already have. As for a refactor, you’d probably want to do something like this:

{-# LANGUAGE BlockArguments #-}
handleMakePlaylist opts = either print putStr =<< runExceptT do
  lib <- readLibFromFile_E opts.libraryPath
  mkPlaylistE lib opts.firstTrack opts.maxTracks

where readLibFromFile_E and mkPlaylistE are of type ExceptT e IO Library and ExceptT e IO Playlist respectively. Note that the e must be the same! You may have to create a custom error type data WhatWentWrong and use throw _AppropriateWhatWentWrongConstructor when you fail in these helper functions.

Let us know how it goes!

I immediately thought to use ExceptT. Transformers are useful tools especially in small amounts, but they can be overused. Transformers work by having a monad as a parameter.

newtype ExceptT e m a = ExceptT (m (Either e a))
type YourCase = ExceptT Error IO -- The same as IO (Either Error a)
-- This is the monad you want in your use case.
ExceptT :: m (Either e a) -> ExceptT e m a 
runExceptT :: ExceptT e m a -> m (Either e a)
-- Are the functions you want
-- Specialized to m=IO
ExceptT :: IO (Either e a) -> ExceptT e IO a 
runExceptT :: ExceptT e IO a -> IO (Either e a)

The reason people don’t like them is because of stuff like:

type MyStack = StateT MyState (ExceptT MyError (ReaderT MyEnv IO)))

Which is a really complex type signatures and has terrible errors, is hard to manipulate, and is too powerful.

The underlying abstraction is “monad”.

Either something transformers/mtl flavoured (like ExceptT) or something effect system flavoured.

The LLM is correct that one approach is ExceptT, and as @mixphix and @ashokkimmel observe, it directly suits your use case. I also agree with the old post: there’s a lot to hate about monad transformers. But for this particular simple use case I think you should just try ExceptT and see what you think.

Problems will arise if you need to do more complicated things, for example, you need to throw and catch error results of a different type or you need to use bracket. If you end up with such needs then I suggest an IO-based (“analytic”) effect system, either effectful or Bluefin.

(But bear in mind that although I unreservedly recommend effect systems, there is far from universal agreement in the community that effect systems are the way to go. For example, see this concurrent thread: You don't need an effect system)

ExceptT used locally is fine. That’s not an effect system. Same with MaybeT. That’s just local control flow.

The complaints are usually about monad transformers stacks used throughout a program.

Thank you all very much for your input, I did some refactoring using ExceptT as suggested, and it works just fine.

Initially, I changed the types of my helper functions from IO (Either AppError x) to ExceptT AppError IO x in order to be able to rewrite the top-level functions as

handleMakePlaylist opts = 
  either print (putStr . renderResolvedPlaylist) =<< runExceptT do
    lib <- readLibFromFile_E opts.libraryPath
    mkPlaylistE lib opts.firstTrack opts.maxTracks

(as initially suggested by @mixphix).

But then I realized I could just keep the helper functions unchanged and use the ExceptT constructor to wrap my existing functions:

handleMakePlaylist opts =
  either print (putStr . renderResolvedPlaylist) =<< runExceptT do
    lib <- ExceptT $ readLibFromFile_ opts.libraryPath
    ExceptT $ mkPlaylist lib opts.firstTrack opts.maxTracks

So I continued refactoring the remaining functions ending up with a similar pattern, but had to fiddle a bit inserting pure here and there to make the types match

handleShowLibrary o =
  either print (putStr . renderTrackCatalog) =<< runExceptT do
    lib <- ExceptT $ readLibFromFile_ o.libraryPath
    pure $ getTracks lib o.title o.key

handleSuggestNextTracks o =
  either print (putStr . renderResolvedTransitions) =<< runExceptT do
    lib <- ExceptT $ readLibFromFile_ o.libraryPath
    ExceptT $ pure $ getTransitions lib o.track o.maxTracks

(that’s because e.g. getTracks just returns a plain MusicLibrary, and getTransitions returns a Either AppError [ResolvedTransition] (no IO on that one!).

How does this look? Any improvement/cleanup that can be done?

Thanks again!

To be honest, I think this obfuscates the code. It is a bit sad that there isn’t a standard function like this:

catchExceptT :: ExceptT e m a -> (e -> m a) -> m a
catchExceptT m hdl = runExceptT m >>= either hdl pure

Then you could write the code more sensibly (in my opinion):

handleMakePlaylist opts = do
    lib <- ExceptT $ readLibFromFile_ opts.libraryPath
    plist <- ExceptT $ mkPlaylist lib opts.firstTrack opts.maxTracks
    liftIO $ putStr $ renderResolvedPlaylist plist
  `catchExceptT` print

Now the “good weather” path is all in one uninterrupted line.

I agree with jaror about needing a special-purpose “catching” function and not relying on either. It’s best to make the “catching code” as simple as possible, so as a first step I think this is an improvement:

handleMakePlaylist opts =
  either print pure =<< runExceptT do
    lib <- ExceptT $ readLibFromFile_ opts.libraryPath
    plist <- ExceptT $ mkPlaylist lib opts.firstTrack opts.maxTracks
    liftIO $ putStr $ renderResolvedPlaylist plist

After that, I slightly prefer handle style to catch style here:

-- Just flipped catchExceptT
handleExceptT :: (e -> m a) -> ExceptT e m a -> m a
handleExceptT = flip catchExceptT

handleMakePlaylist opts = handleExceptT print $ do
  lib <- ExceptT $ readLibFromFile_ opts.libraryPath
  plist <- ExceptT $ mkPlaylist lib opts.firstTrack opts.maxTracks
  liftIO $ putStr $ renderResolvedPlaylist plist

Then I think this is a very good baseline for you to continue with. I think you have a few alternative styles you can experiment with:

  1. Push the ExceptT into the function definitions (a matter of taste)
  2. Use mtl type classes (probably not warranted for such a small example, but might be useful as you write m ore)
  3. Use an effect system (ditto)
  4. Throw and catch IO exceptions, ReaderT _ IO/rio style (this is the approach of @vshabanov below – I find that rather unpleasant because it fails to take advantage of Haskell’s referential transparency)

But I wouldn’t recommend experimenting unless experimenting is actually your goal. What you have is a fine style to implement real, simple programs with.

You could do instead

handleMakePlaylist opts =
  either print pure =<< runExceptT do
    lib <- ExceptT $ readLibFromFile_ opts.libraryPath
    plist <- ExceptT $ mkPlaylist lib opts.firstTrack opts.maxTracks
    ExceptT $ putStr $ renderResolvedPlaylist plist

This mostly does your code, and keeps a separation of the success and fail cases.

You could use plain exceptions, i.e. readLibFromFile_ and mkPlaylist could simply fail or throwIO on error, and then you get a straightforward

handleMakePlaylist :: MakePlaylistOptions -> IO ()
handleMakePlaylist opts = do
    lib <- readLibFromFile_ opts.libraryPath
    plist <- mkPlaylist lib' opts.firstTrack opts.maxTracks
    putStr $ renderResolvedPlaylist plist'

Or you can make all functions pure with Either String ... results and chain them using the plain Either monad:

handleMakePlaylist :: MakePlaylistOptions -> IO ()
handleMakePlaylist opts = do
    contents <- readFile opts.libraryPath -- throws, could be wrapped with `try`
    putStr $ either ("Error: " <>) id $ do 
        lib <- parseLib contents 
        plist <- mkPlaylist lib' opts.firstTrack opts.maxTracks
        Right $ renderResolvedPlaylist plist'

Local use of ExceptT is fine too, but I find the two approaches above easier.

Thank you all for the follow-up input!

So it seems there are essentially two main approaches here:

  • use transformers such as ExceptT, and perhaps (if the program gets more complex) look at more advanced stuff such as effect systems etc.
  • keep it simple (the “KISS principle” as somebody mentioned to me); use fail, exceptions etc. or maybe just a couple of helpers to deal with my particular case (without introducing new full blown abstractions such as ExceptT).

I’m not sure where I stand yet as I do believe in simplicity (especially for a simple/small program lime mine) but at the same time I don’t want to reinvent the wheel and I think using battle-tested libraries should usually be preferred to writing your own.

Anyway, for now I’m sticking to ExceptT with some extra cleanup as suggested by @jaror and @tomjaguarpaw:

handleMakePlaylist :: MakePlaylistOptions -> IO ()
handleMakePlaylist opts =
  handleExceptT print do
    lib <- ExceptT $ readLibFromFile_ opts.libraryPath
    plist <- ExceptT $ mkPlaylist lib opts.firstTrack opts.maxTracks
    liftIO $ putStr $ renderResolvedPlaylist plist

I can see how this can turn into a huge rabbit hole so I’ll leave it like this for now and move on to clean up other parts of the app :slight_smile:

I’ve just remembered the downside of ExceptT .. IO – two sources of exceptions. ExceptT handles the Either .. in IO (Either ..), but IO itself can throw exceptions too and blow up all the nice ExceptT handling. So you would need the cumbersome

tryJoin :: IO (Either String a) -> IO (Either String a)
tryJoin = fmap (join . first show) . try @SomeException

handleMakePlaylist opts =
  handleExceptT print do
    lib <- ExceptT $ tryJoin $ readLibFromFile_ opts.libraryPath
    plist <- ExceptT $ tryJoin $ mkPlaylist lib opts.firstTrack opts.maxTracks
    liftIO $ putStr $ renderResolvedPlaylist plist

And it would still be suboptimal – it’s easy to forget tryJoin and it will swallow asynchronous exceptions.

Another problem with ExceptT .. IO is concurrency. You’ll need even more cumbersome IO wrapping/unwrapping.

So the local use ExceptT over the pure monad is fine. Otherwise it’s better to stick to plain IO with exceptions and pure functions.

I disagree that it’s “better”, it’s just a matter of taste. I personally wouldn’t risk crashing my entire program just because I misparsed the library file. Distinguishing between pure errors and runtime exceptions allows you to be more precise about the program semantics, especially when learning.

But pure code can throw exceptions too, so shouldn’t your argument judge all uses of ExceptT, whether pure or around IO, as equally bad?

I personally wouldn’t risk crashing my entire program just because I misparsed the library file

Not sure how this is connected to what I wrote before. Let me try to expand a bit.

If you write ExceptT .. IO and do not handle IO exceptions, the program will still crash. You need to handle two layers of exceptions, which is both tiresome and error-prone (and adding concurrency is a pain).

That’s why, as I wrote in my first message, it’s worth to go with plain exceptions (and indeed to catch them at the top level). Or even better, write a thin IO layer (readFile in this case) and process everything else in pure functions (just Either, no need in EitherT).

This is what I meant by saying plain IO with exceptions (readFile) and pure functions (parsePlaylist :: String -> Either String Playlist).

You get pure code (no hidden IO that will bite you) with simple types, easily testable, composable and parallelisable.

Distinguishing between pure errors and runtime exceptions allows you to be more precise about the program semantics, especially when learning.

And this is exactly what readFile and parse :: ... -> Either ... do. One leads to a runtime error (“openFile: does not exist”), and the other to a pure error (Left “Can’t parse xxx”).

A chain of EitherT $ throwAtRandom is an odd mix of pure and impure exceptions that creates more problems than it solves.

Related reading:

In a sense, yes. Having a second layer of exceptions, whether IO or “pure” ones, defeats the purpose of ExceptT – the code that is supposed to handle errors no longer does so.

There’s a big difference between the two kinds of exceptions though. An IO error is the norm (we don’t control the outside world, a file may not exist), while pure errors are usually logical errors – bugs in a program (unless you use error "foo" for error handling, in which case you clearly don’t need ExceptT).

Considering Haskell’s laziness, the pure code can easily fail after runExceptT has returned its value.

And “pure” exceptions are still technically IO exceptions – you can catch them.

So the point is moot – pure code throws IO exceptions and escapes ExceptT. This is not something that should normally happen.

And my argument included concurrency, not just exceptions.

Nice! I don’t remember reading it before. It says the same things:

It is almost always wrong to wrap an ExceptT, EitherT, or ErrorT around an IO-based transformer stack.
…
Please note that using ExceptT, etc with a non-IO base monad (for example with pure code) is a perfectly fine pattern.

I’m not sure about the “MonadThrow” part though. It leads to MonadCatch and MonadMask everywhere, but what for? How often do we replace IO exceptions?

The “Transformers” part is outdated, Snoyman himself added the much more straightforward MonadUnliftIO a few years later.

Other than that, it’s very good.

Thanks, I’m going to take a look!
And thanks everyone again for the input.

Glad my post sparked some discussion :slight_smile:

I think I agree with this distinction but I’m wondering: What do you do then if you have to interleave IO with your pure functions? (and you want to continue based on the results of your pure functions). Don’t you end up with a staircase again?

doStuff :: IO ()
doStuff = do
    f <- readFile "..."
    case parseFile f of
        Left e -> throw $ ParseException e
        Right parsed -> do
            f2 <- readFile $ getFileName parsed
            case parseFile2 f2 of
                Left e' -> throw $ ParseException e'
                Right parsed' -> ...