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.