I've run into the same scenario involving Monad Transformers a few times now, and I'm looking for some "best-practice" advice.
Bear with the introduction, the issue itself is really simple:
Say you're given a list of inputs, and you want to convert them into some aggregated form while handling the possibility of invalid inputs. In code, we could have something like:
type Input = String
type ProcessedInput = String
type Output = S.Set ProcessedInput
data Error = InvalidFormat Input
| DuplicateInput ProcessedInput
processInput :: Input -> Maybe ProcessedInput
-- Useful later, not vital to the problem
ifM :: Monad m => m Bool -> m a -> m a -> m a
ifM cond t f = cond >>= \c -> if c then t else f
and we want something like:
buildInputs :: [Input] -> Either Error Output
I've found that it's normally nicer to compute this inside a little isolated monad transformer stack (StateT
around an Except
, so we can build up our result and handle errors when needed) instead of doing a bunch of maps and any
checks and aggregations.
So the question: Is it "better" to express this computation as acting on single inputs, or acting on the entire input? Bearing in mind that this is an entirely self-contained computation, it won't need to be used as a subcomputation anywhere else.
Using a "single-input" action requires some slightly unintuitive wrapping in buildInputs
:
buildInputs :: [Input] -> Either Error Output
buildInputs is = runExcept $ execStateT builder S.empty
where builder = sequence (map buildInputs' is)
buildInputs' :: Input -> StateT Output (Except Error) ()
buildInputs' i = case processInput i of
Nothing -> throwError (InvalidFormat i)
Just p -> ifM (gets $ S.member p)
(throwError $ DuplicateInput p)
(modify $ S.insert p)
Using a "whole-input" action gives a cleaner wrapper at the cost of a messier body:
buildInputs :: [Input] -> Either Error Output
buildInputs is = runExcept $ execStateT (buildInputs2' is) S.empty
buildInputs' :: [Input] -> StateT Output (Except Error) ()
buildInputs' [] = return ()
buildInputs' (i:is) = case processInput i of
Nothing -> throwError (InvalidFormat i)
Just p -> do
ifM (gets $ S.member p)
(throwError $ DuplicateInput p)
(modify $ S.insert p)
buildInputs' is
Is there a best-practice convention for this sort of thing? Is there an entirely better way of doing this that I've missed?