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.
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With