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!
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.
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.
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)
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
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?
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:
Push the ExceptT into the function definitions (a matter of taste)
Use mtl type classes (probably not warranted for such a small example, but might be useful as you write m ore)
Use an effect system (ditto)
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.
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:
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
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.
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.
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 afterrunExceptT 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.
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' -> ...