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.