7

This is probably a very basic Haskell question, but let's assume the following function signatures

-- helper functions
getWeatherInfo :: Day -> IO (Either WeatherException WeatherInfo)
craftQuery :: WeatherInfo -> Either QueryException ModelQuery
makePrediction :: ModelQuery -> IO (Either ModelException ModelResult)

The naive way of chaining all the above into one predict day function could be:

predict :: Day -> IO (Maybe Prediction)
predict day = do
    weather <- getWeatherInfo day
    pure $ case weather of
        Left ex -> do
            log "could not get weather: " <> msg ex
            Nothing
        Right wi -> do
            let query = craftQuery wi
            case query of
                Left ex -> do
                    log "could not craft query: " <> msg ex
                    Nothing
                Right mq -> do
                    prediction <- makePrediction mq
                    case prediction of
                        Left ex -> do
                            log "could not make prediction: " <> msg ex
                            Nothing
                        Right p ->
                            Just p

In more imperative languages, one could do something like:

def getWeatherInfo(day) -> Union[WeatherInfo, WeatherError]:
    pass

def craftQuery(weather) -> Union[ModelQuery, QueryError]:
    pass

def makePrediction(query) -> Union[ModelResult, ModelError]:
    pass

def predict(day) -> Optional[ModelResult]:
    weather = getWeatherInfo(day)
    if isinstance((err := weather), WeatherError):
        log(f"could not get weather: {err.msg}")
        return None

    query = craftQuery weather
    if isinstance((err := query), QueryError):
        log(f"could not craft query: {err.msg}")
        return None

    prediction = makePrediction query
    if isinstance((err := prediction), ModelError):
        log(f"could not make prediction: {err.msg}")
        return None

    return prediction

Which is arguably less type-safe and clunkier in many ways, but, also arguably, much flatter. I can see that the main difference is that in Python we can (whether we should is a different story) use make multiple early return statements to stop the flow at any stage. But this is not available in Haskell (and anyway this would look very un-idiomatic and defeat the whole purpose of using the language in the first place).

Nevertheless, is it possible to achieve the same kind of "flatness" in Haskell when dealing with the same logic of chaining successive Either/Maybe one after the other?

-- EDIT following the duplicate suggestion:

I can see how the other question is related, but it's only that (related) — it doesn't answer the question exposed here which is how to flatten a 3-level nested case. Furthermore this question (here) exposes the problem in a much more generic manner than the other one, which is very use-case-specific. I guess answering this question (here) would be beneficial to other readers from the community, compared to the other one.

I understand how obvious it seems to be for seasoned Haskellers that "just use EitherT" sounds like a perfectly valid answer, but the point here is that this question is asked from the perspective of someone who is not a seasoned Haskeller, and also who's read over and again that Monad transformers have their limitations, and maybe Free monad or Polysemy or other alternatives would be best, etc. I guess this would be useful for the community at large to have this specific question answered with different alternatives in that regard, so the newbie Haskeller can find himself slightly less "lost in translation" when starting to be confronted with more complex codebases.

Jivan
  • 21,522
  • 15
  • 80
  • 131
  • 5
    You can work with a [**`MaybeT`**](https://hackage.haskell.org/package/transformers-0.5.6.2/docs/Control-Monad-Trans-Maybe.html#t:MaybeT) *monad transformer*. – Willem Van Onsem May 20 '21 at 09:43
  • @WillemVanOnsem what if `predict` returns an `IO (Either _ _)` as well? We don't need a transformer anymore, do we? But how could we get rid of this nested `case .. of` in this case? – Jivan May 20 '21 at 09:51
  • 1
    you can wrap it in a `MaybeT` with the [`MaybeT` constructor](https://hackage.haskell.org/package/transformers-0.5.6.2/docs/Control-Monad-Trans-Maybe.html#t:MaybeT): that turns a `m (Maybe a)` into a `MaybeT m a`. – Willem Van Onsem May 20 '21 at 10:17
  • 1
    The same holds for an `EitherT`: https://hackage.haskell.org/package/EitherT-0.2.0/docs/Control-Monad-Trans-Either.html – Willem Van Onsem May 20 '21 at 10:17
  • 2
    Does this answer your question? [How to simplify the error handling in (IO (Either a b))](https://stackoverflow.com/questions/53426475/how-to-simplify-the-error-handling-in-io-either-a-b) – Mark Seemann May 20 '21 at 11:23
  • @MarkSeemann I can see how this other question is related, but it's only that (related) — it doesn't answer the question exposed here which is how to flatten a 3-level nested `case`. Furthermore this question (here) exposes the problem in a much more generic manner than the other one, which is very use-case-specific. I guess answering this question (here) would be beneficial to other readers from the community, compared to the other one. – Jivan May 20 '21 at 11:56
  • 2
    @Jivan I can see that it looks that way, but that other answer *does* show how to get rid of a single `case`. In essence, it flattens a '1-level' nested `case`. There are plenty of other examples here on Stack Overflow that address the issue: https://stackoverflow.com/q/33005903/126014, https://stackoverflow.com/q/52016330/126014, https://stackoverflow.com/q/50136713/126014 – Mark Seemann May 20 '21 at 12:44
  • 1
    Thanks for the additional links. The profusion of these questions/answers could be a sign that the existing answers are not searchable enough (I did Google as I could before asking here), or that the existing solutions for the issue (e.g. monad transformers) are not intuitive to Haskell relative beginners. There's a lot of machinery to be familiar with before being able to even grasp what monad transformers are about. – Jivan May 20 '21 at 13:03
  • 1
    @Jivan That's a fair criticism. You raise two points, only one of which I can address. The one that I can't do much about is Haskell's steep learning curve. The other point I can address. If you edit the question so that it's a [Minimal, Reproducible Example](https://stackoverflow.com/help/minimal-reproducible-example) I can take a stab at answering it. – Mark Seemann May 20 '21 at 14:17
  • 1
    @MarkSeemann thanks for kindly suggesting this. I was in the process of adapting the question in that direction, when suddenly I read the answer posted by leftaroundabout. Given the quality of this answer I figured I’d better leave the question intact so that Q+A remain consistent with one another. – Jivan May 20 '21 at 16:47

1 Answers1

12

To “reverse deduce” that monad transformers are the right tool here, consider the situation where no IO is needed (e.g. because the weather information comes from a static database that's already in memory):

getWeatherInfo' :: Day -> Either WeatherException WeatherInfo
craftQuery :: WeatherInfo -> Either QueryException ModelQuery
makePrediction' :: ModelQuery -> Either ModelException ModelResult

Your example now looks like

predict' :: Day -> Maybe Prediction
predict' day =
    let weather = getWeatherInfo' day
    in case weather of
        Left ex ->
            Nothing
        Right wi -> do
            let query = craftQuery wi
            in case query of
                Left ex ->
                    Nothing
                Right mq ->
                    let prediction = makePrediction' mq
                    in case prediction of
                        Left ex ->
                            Nothing
                        Right p ->
                            Just p

Just about any Haskell tutorial explains how this can be flattened, using the fact that Maybe is a monad:

predict' :: Day -> Maybe Prediction
predict' day = do
    let weather = getWeatherInfo' day
    weather' <- case weather of
      Left ex -> Nothing
      Right wi -> Just wi
    let query = craftQuery weather'
    query' <- case query of
      Left ex -> Nothing
      Right mq -> Just mq
    let prediction = makePrediction' query'
    prediction' <- case prediction of
      Left ex -> Nothing
      Right p -> Just p
    return prediction'

It's a bit awkward to always bind variableName with let before extracting variableName' from the monad. Here it's actually unnecessary (you can just put getWeatherInfo' day itself in the case statement), but note that it could more generally be this situation:

predict' :: Day -> Maybe Prediction
predict' day = do
    weather <- pure (getWeatherInfo' day)
    weather' <- case weather of
      Left ex -> Nothing
      Right wi -> Just wi
    query <- pure (craftQuery weather')
    query' <- case query of
      Left ex -> Nothing
      Right mq -> Just mq
    prediction <- pure (makePrediction' query')
    prediction' <- case prediction of
      Left ex -> Nothing
      Right p -> Just p
    return prediction'

The point being, the stuff you're binding to weather could itself be in the Maybe monad.

One way to avoid the essentially duplicate variable names is to use the lambda-case extension, this allows you to eta-reduce one of them away. Furthermore, the Just and Nothing values are only a specific example of pure and empty, with which you get this code:

{-# LANGUAGE LambdaCase #-}

import Control.Applicative

predict' :: Day -> Maybe Prediction
predict' day = do
    weather <- pure (getWeatherInfo' day) >>= \case
      Left ex -> empty
      Right wi -> pure wi
    query <- case craftQuery weather of
      Left ex -> empty
      Right mq -> pure mq
    prediction <- pure (makePrediction' query) >>= \case
      Left ex -> empty
      Right p -> pure p
    return prediction

Nice, but you can't work in simply the Maybe monad because you also have effects of the IO monad. In other words, you don't want Maybe to be the monad, but rather place its short-circuiting property on top of the IO monad. Hence you transform the IO monad. You can still lift plain old non-transformed IO action into the MaybeT stack, and still use pure and empty for the maybe-ness, thus getting almost the same code as without IO:

predict :: Day -> MaybeT IO Prediction
predict day = do
    weather <- liftIO (getWeatherInfo day) >>= \case
      Left ex -> empty
      Right wi -> pure wi
    query <- case craftQuery weather of
      Left ex -> empty
      Right mq -> pure mq
    prediction <- liftIO (makePrediction query) >>= \case
      Left ex -> empty
      Right p -> pure p
    return prediction

Finally, you could now go further and also use a transformer layer to handle your logging in a better way. It can be done with WriterT. The advantage over logging in IO is that the log doesn't just end up somewhere, but the caller of your function will know a log is created and can decide whether to put that in a file or show it directly on the terminal or simply discard it.

But since you always seem to just log the Nothing cases, a better option is to not use the Maybe transformer at all but the Except one instead, since that seems to be your idea:

import Control.Monad.Trans.Except

predict :: Day -> ExceptT String IO Prediction
predict day = do
    weather <- liftIO (getWeatherInfo day) >>= \case
      Left ex -> throwE $ "could not get weather: " <> msg ex
      Right wi -> pure wi
    query <- case craftQuery weather of
      Left ex -> throwE $ "could not craft query: " <> msg ex
      Right mq -> pure mq
    prediction <- liftIO (makePrediction query) >>= \case
      Left ex -> throwE $ "could not make prediction: " <> msg ex
      Right p -> pure p
    return prediction

Indeed, probably your primitives should have been in that monad in the first place, then it gets even more concise:

getWeatherInfo :: Day -> ExceptT WeatherException IO WeatherInfo
makePrediction :: ModelQuery -> ExceptT ModelException IO WeatherInfo

predict day = do
    weather <- withExcept (("could not get weather: "<>) . msg)
       $ getWeatherInfo day
    query <- withExcept (("could not craft query: "<>) . msg)
        $ except (craftQuery weather)
    prediction <- withExcept (("could not make prediction: "<>) . msg)
        $ makePrediction query
    return prediction

Finally-finally note that you don't really need to bind the intermediate variables, since you always just pass them on the the next action. I.e., you have a composition chain of Kleisli arrows:

predict = withExcept (("could not get weather: "<>) . msg)
                   . getWeatherInfo
      >=> withExcept (("could not craft query: "<>) . msg)
                   . except . craftQuery
      >=> withExcept (("could not make prediction: "<>) . msg)
                   . makePrediction
leftaroundabout
  • 117,950
  • 5
  • 174
  • 319
  • 1
    I'm nothing short of blown away by the quality and clarity of this answer. Thanks a million. – Jivan May 20 '21 at 15:34
  • Thanks for appreciating it! — I couldn't help adding a final splinter of functional mother-of-pearl... – leftaroundabout May 20 '21 at 16:41
  • Kleisli composition is the most elegant thing ever. I’ve used them occasionally with only one level of monad (IO) but I have to admit that doing what you did here is way more impressive. – Jivan May 20 '21 at 16:52