Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Reduce nestedness when using successive Either/Maybe

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.

like image 612
Jivan Avatar asked May 20 '21 09:05

Jivan


1 Answers

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
like image 141
leftaroundabout Avatar answered Nov 15 '22 07:11

leftaroundabout