Refactoring Nested Case Expressions in PureScript
I often find myself writing "branching code" in PureScript. This results in nested case expressions that are akin to JavaScript's pyramid of doom. Take this function for example...
compileToConfig :: String -> Either Error SerializedConfig
compileToConfig story =
case parseManyStories story of
Left err -> Left $ parseErrorToError err
Right s -> case Array.head s of
Just ss -> Right $ SerializedConfig {
config : GameConfig {
startLocation : (view _name ss),
aliasGroups : mempty
},
stories : s
}
_ -> Left $ error $ "No story parts could be parsed from the input."
parseErrorToError :: ParseError -> Error
parseErrorToError err = error $
parseErrorMessage err <> ". " <>
(show $ parseErrorPosition err)
It has two possible failure cases - The first is a parse error, and the second is a content error. Let's clean this function up by flattening the pyramid.
The first thing to notice is that the the return type is Either
, and that the two possible failure points return Either
and Maybe
. The Eithers differ in their Left
constructor though - one returns a ParseError
and the other returns a regular Error
. If we can convert Either ParseError
into Either Error
then we can flatten the computation by taking advantage of Either's Monad instance and do-notation.
It turns out we can easily convert Either ParseError
into Either Error
by using Bifunctor's lmap
function. lmap
will run a function only on the Left
value of Either
, and leave the Right
value alone. So to start, we can run the parseManyStories
function as follows...
compileToConfig :: String -> Either Error SerializedConfig
compileToConfig story = do
stories <- lmap parseErrorToError $ parseManyStories story
The next failure point is the Array.head
function. Since head
returns Maybe a
we can't bind the results of this computation inside our function. That is, we can't do the following...
compileToConfig :: String -> Either Error SerializedConfig
compileToConfig story = do
stories <- lmap parseErrorToError $ parseManyStories story
-- This will not work.
firstStory <- Array.head stories
This is because we're running our function in the "Either context" - function calls with the form x <- fn
inside compileToConfig
MUST return a Either Error a
. So we need to find a way to convert a Maybe a
into an Either Error a
. Let's continue rewriting the function, and use PureScript's typed-hole feature to ask the compiler if a function exists to convert Maybe a
into an Either Error a
.
compileToConfig :: String -> Either Error SerializedConfig
compileToConfig story = do
stories <- lmap parseErrorToError $ parseManyStories story
firstStory <- ?whatgoeshere noStoryPartsError (Array.head stories)
pure $ SerializedConfig {
config : GameConfig {
startLocation : (view _name firstStory),
aliasGroups : mempty
},
stories : stories
}
where
noStoryPartsError =
error $ "No story parts could be parsed from the input."
Now the compiler will annotate the ?whatgoeshere
typed-hole with the following message...
[PureScript]
Hole 'whatgoeshere' has the inferred type
Error -> Maybe ParsedStory -> Either Error ParsedStory
You could substitute the hole with one of these values:
Data.Either.note :: forall a b. a -> Maybe b -> Either a b
Unsafe.Coerce.unsafeCoerce :: forall a b. a -> b
in the following context:
noStoryPartsError :: Error
stories :: Array ParsedStory
story :: String
in value declaration compileToConfig
Which tells us there is a function named note
in Data.Either
that's exactly what we need. Here's what our final refactored function looks like.
compileToConfig :: String -> Either Error SerializedConfig
compileToConfig story = do
stories <- lmap parseErrorToError $ parseManyStories story
firstStory <- note noStoryPartsError (Array.head stories)
pure $ SerializedConfig {
config : GameConfig {
startLocation : (view _name firstStory),
aliasGroups : mempty
},
stories : stories
}
where
noStoryPartsError =
error $ "No story parts could be parsed from the input."
No more pyramid!
As a side note, this same technique can be applied to effectful functions. Some examples of computational contexts that implicitly handle failure are Aff e a
, Except
, ExceptT
, or any Monad with a MonadError
instance.