0
votes

Trying to wrap my head around monad transformers, I could get some toy examples to work but here I'm struggling with a slightly more real-worldy use case. Building upon this previous question, with a more realistic example using ExceptT, where three helper functions are defined.

{-# LANGUAGE RecordWildCards #-}

-- imports so that the example is reproducible
import           Control.Monad.IO.Class     (MonadIO (liftIO))
import           Control.Monad.Trans.Except
import qualified Data.List                  as L
import           Data.Text                  (Text)
import qualified Data.Text                  as T
import           System.Random              (Random (randomRIO))

-- a few type declarations so the example is easier to follow
newtype Error = Error Text deriving Show
newtype SQLQuery = SQLQuery Text deriving Show
newtype Name = Name { unName :: Text } deriving Show
data WithVersion = WithVersion { vName :: Name, vVersion :: Int }

-- | for each name, retrieve the corresponding version from an external data store
retrieveVersions :: [Name] -> ExceptT Error IO [WithVersion]
retrieveVersions names = do
    doError <- liftIO $ randomRIO (True, False) -- simulate an error
    if doError
        then throwE $ Error "could not retrieve versions"
        else do
            let fv = zipWith WithVersion names [1..] -- just a simulation
            pure fv

-- | construct a SQL query based on the names/versions provided
-- (note that this example is a toy with a fake query)
mkQuery :: [WithVersion] -> SQLQuery
mkQuery withVersions =
    SQLQuery $ mconcat $ L.intersperse "\n" $ (\WithVersion {..} ->
        unName vName <> ":" <> T.pack (show vVersion)
    ) <$> withVersion

-- | query an external SQL database and return result as Text
queryDB :: SQLQuery -> ExceptT Error IO Text
queryDB q = do
    doError <- liftIO $ randomRIO (True, False) -- simulate an error
    if doError
        then throwE $ Error "SQL error"
        else do
            pure "This is the result of the (successful) query"

The calls to randomRIO are there to simulate the possibility of an error. If doError is True, then the helper returns what would be the equivalent of Left $ Error "message" if using Either.

All the helpers above compile just fine, however the example wrapper function below doesn't compile:

-- | given a list of names, retrieve versions, build query and retrieve result
retrieveValues :: [Name] -> ExceptT Error IO Text
retrieveValues names = do
    eitherResult <- runExceptT $ do
        withVersions <- retrieveVersions names
        let query = mkQuery withVersions
        queryDB query
    case eitherResult of
        Left err     -> throwE err
        Right result -> pure result

The error given by GHC is as follows:

• Couldn't match type ‘IO’ with ‘ExceptT Error IO’
  Expected type: ExceptT Error IO (Either Error Text)
    Actual type: IO (Either Error Text)
• In a stmt of a 'do' block:
    eitherResult <- runExceptT
                      $ do withVersions <- retrieveVersions names
                           let query = mkQuery withVersions
                           queryDB query

I've tried using various functions in place of runExceptT, namely runExcept, withExcept, withExceptT but none of them worked. The closed I can get between Expected and Actual types is with runExceptT.

What should be changed in order for retrieveValues to compile and properly return "either" an Error or a result in the form of a Text?

I also think that using caseEitherResult of might be redundant here because all it does is essentially passing on either the result or the error, with no additional processing, so I attempted a more direct version, which also fails:

retrieveValues :: [Name] -> ExceptT Error IO Text
retrieveValues names = do
    runExceptT $ do
        withVersions <- retrieveVersions names
        let query = mkQuery withVersions
        queryDB query
1
You don't need runExceptT at all. The declared return type of your retrieveValues function is an ExceptT, not an IO (Either Error Text)4castle
now I feel embarrassed :)Jivan
@4castle thanks for this (in insight very obvious) answer. That was probably the result of much confusion between this example thing and the larger context of the real-world thing I'm trying to build. Now this works perfectly.Jivan

1 Answers

2
votes

Think carefully about what monads your various do blocks are using. Let's start by taking a look at your first definition of retrieveValues:

retrieveValues :: [Name] -> ExceptT Error IO Text
retrieveValues names = do
    eitherResult <- runExceptT $ do
        withVersions <- retrieveVersions names
        let query = mkQuery withVersions
        queryDB query
    case eitherResult of
        Left err     -> throwE err
        Right result -> pure result

This function lives in the ExceptT Error IO monad, which means that each statement in the top do block needs to be in that monad. However, your first statement eitherResult <- runExceptT $ do ... doesn't live there. The type of runExceptT is ExceptT e m a -> m (Either e a), in this case specialized to ExceptT Error IO Text -> IO (Either Error Text), which means that it lives in the IO monad, not ExceptT Error IO! To fix this, you need to lift the result. So, the line should look like:

    eitherResult <- lift $ runExceptT $ do

Your second definition is also very close to working, but you didn't quite delete enough when modifying it from your first. You wrote:

retrieveValues :: [Name] -> ExceptT Error IO Text
retrieveValues names = do
    runExceptT $ do
        withVersions <- retrieveVersions names
        let query = mkQuery withVersions
        queryDB query

The question you should ask yourself is: Do I even need the third line? In other words, if your result should be ExceptT Error IO Text and your inner do block is of type ExceptT Error IO Text, then why are you calling runExceptT at all? Or, perhaps your goal was to produce an Either as a result of this function, so the runExceptT is critical, but now the type doesn't make sense. In other words, there are two ways to fix this. First, you could fix the implementation to match the type, simply by getting rid of that third line:

retrieveValues :: [Name] -> ExceptT Error IO Text
retrieveValues names = do
    withVersions <- retrieveVersions names
    let query = mkQuery withVersions
    queryDB query

Alternatively, you could change the type to match the implementation:

retrieveValues :: [Name] -> IO (Either Error Text)
retrieveValues names = do
    runExceptT $ do
        withVersions <- retrieveVersions names
        let query = mkQuery withVersions
        queryDB query

(Note in general that a do block with only a single statement doesn't need to be in a do block at all. So in this case, you could remove the first do without altering the program at all.)