Errors are Values Too

Posted on May 5, 2021 by Marko Dimjašević

When reading software source code and trying to understand what it does, I am definitely interested in what it returns on a happy path, i.e., when the input leads to a successful result. However, typically things can go wrong too and I am equally interested in what ways a computation can go wrong. In typed functional programming we tend to communicate the happy path case via the return type, and I find it rather useful to do the same for the things-going-wrong cases.

Communicating the Possibility of Failure

Consider the following simple program which reads an integer from the standard input and prints “even” in case it is an even number, and “odd” otherwise:

prog :: IO ()
prog = do
  line <- getLine
  let num = read line
  if even num
    then putStrLn "even"
    else putStrLn "odd"

If we load the program in GHCi and run it with 6 as input, everything looks good:

*ProgRead> prog
6
even

However, let’s consider a slightly more complex task: printing the same message as above, but for five numbers in a row.

It looks like we could simply extend the program above with this:

loop :: IO ()
loop = sequence_ (replicate 5 prog)

It works fine when inputs are valid:

*Loop> loop
1
odd
2
even
3
odd
4
even
5
odd

However, as soon as we enter something that is not a number, the program fails without giving us a chance to recover:

*Loop> loop
1
odd
a
*** Exception: Prelude.read: no parse

The read function, that is used in the prog definition, invokes the error function as soon as parsing an integer fails. It is very likely you do not want your program to fail, but to handle the situation by providing a warning message and moving on to the next iteration step. Because read does not communicate via its type signature that it can fail, and instead performs short-circuiting via error, we cannot reuse the prog program for our extended task; prog is not composable because it was overly specific in a failing case.

What we could do instead is to rewrite prog such that a type communicates the possibility of failure:

maybeInt :: IO (Maybe Int)

Now, we inform the user of maybeInt via the type that a failure in obtaining an integer is possible. The modified program could be written as follows:

module LoopMaybe where

import Text.Read (readMaybe)

maybeInt :: IO (Maybe Int)
maybeInt = readMaybe <$> getLine

loopMaybe :: IO ()
loopMaybe = sequence_ (replicate 5 processNum) where
  processNum :: IO ()
  processNum = do
    mNum <- maybeInt
    case mNum of
      Nothing -> putStrLn "not a number"
      Just num -> if even num
        then putStrLn "even"
        else putStrLn "odd"

Here is an example interaction with the program where an invalid value occurs half way through:

*LoopMaybe> loopMaybe
1
odd
a
not a number
3
odd
4
even
5
odd

The Maybe monad is used in cases where we do not care about the specific error that failed the computation, but we still want to handle the failure somehow. In case we need to know what led to a failure, we can use the Either err monad (for some error type err).

Avoiding being Overly Specific Too Soon

The example tasks above demonstrate that avoiding to short-circuit is important if we strive to have composable and reusable code. When short-circuiting, exactly one interpretation of an erroneous state is hard-coded and no recovery is possible, which hinders compositionality and reusability. To make code composable, we extended a return type signature to singal the possibility of failure, and consequently we conisdered failing cases by pattern-matching on the return value at usage sites.

An example that hinders compositionality and reusability, assuming one does not have to squint an eye when using types1, can be seen in Yesod, a web framework. Its HandlerFor site monad implements a too-specific error handling strategy: as soon as a check fails, Yesod allows one to short-circuit by returning a 400 HTTP error response. Yesod error handling is problematic in two ways from a type-system point of view: 1) it is overly specific by returning HTTP responses instead of abstract errors that the caller can handle in whatever way suitable, and 2) the signature of the handler gives no hint the computation can fail, let alone what errors it can end in, misguiding the developer reading the code that the handler always succeeds. A countermeasure is to bolt an Either err monad on top of the handler monad, i.e., to have

    HandlerFor site (Either err a)

but the GHC type-checker cannot guarantee that all errors are communicated via the inner Either err monad when the outer monad is HandlerFor site.

To avoid this kind of situation where multiple concerns and levels of error communication and handling are lumped together, in implementing the Connect Four board game I separated Servant’s HTTP request handling from implementing the game logic. That way when a validation fails in the logic, it cannot return a 400 HTTP response because it knows nothing about HTTP requests and responses! All it can do is return a plain algebraic data type error value, which consequently the web server maps to a 400 HTTP error response.

Verbosity in Communicating Errors

Error handling can be verbose. For example, this is how I first implemented a function for finalising a new game in connect4:

data FinaliseNewGameError
  = FinaliseEmptyPlayerName
  | FinaliseUnknownGame GameId
  | FinaliseTakenName PlayerName GameId
  deriving (Eq, Show)

-- | Finalises the initialisation of an open game.
finaliseNewGame
  :: ( Member (KVS GameId Player) r
     , Member (KVS GameId GameStatus) r
     , Member (Error FinaliseNewGameError) r
     , Member Random r
     )
  => PlayerName
  -> GameId
  -> Sem r PlayerToken
finaliseNewGame (isNameEmpty -> True) _ =
  throw FinaliseEmptyPlayerName
finaliseNewGame secondPlayer' gameId = do
  mGame <- getKVS @GameId @Player gameId
  case mGame of
    Nothing          -> throw (FinaliseUnknownGame gameId)
    Just firstPlayer -> do
      let secondPlayer = dropWhitespace secondPlayer'
      if playerName firstPlayer == secondPlayer
        then throw (FinaliseTakenName secondPlayer gameId)
        else do
          secondToken <- random
          whichPlayer <- random
          let initialisedGame = MkInProgressGameState
                { inProgressGrid         = emptyGrid
                , inProgressOnMove       = whichPlayer
                , inProgressFirstPlayer  = firstPlayer
                , inProgressSecondPlayer = MkPlayer secondPlayer secondToken
                }
          insertKVS gameId (InProgress initialisedGame)
          deleteKVS @GameId @Player gameId
          pure secondToken

I would explicitly check if a specific game exists and if the game exists, check if a player name is already used. This is rather verbose and the code layout gets quite nested, yet there were only two checks to perform. Things get worse as more validation and parsing is needed.

Recall that a key feature of monads is the ability to sequence computation, e.g., we can make the outcome of one step affect the course of computation that follows. By using Control.Monad.when instead of the if construct, and by using Polysemy.Error.note instead of explicitly pattern-matching on an optional value, I was able to flatten out the finaliseNewGame function:

-- | Finalises the initialisation of an open game.
finaliseNewGame
  :: ( Member (KVS GameId Player) r
     , Member (KVS GameId GameStatus) r
     , Member (Error FinaliseNewGameError) r
     , Member Random r
     )
  => PlayerName
  -> GameId
  -> Sem r PlayerToken
finaliseNewGame (isNameEmpty -> True) _ =
  throw FinaliseEmptyPlayerName
finaliseNewGame secondPlayer' gameId = do
  mGame <- getKVS @GameId @Player gameId
  firstPlayer <- note (FinaliseUnknownGame gameId) mGame
  let secondPlayer = dropWhitespace secondPlayer'
  when (playerName firstPlayer == secondPlayer)
    . void . throw $ FinaliseTakenName secondPlayer gameId
  secondToken <- random
  whichPlayer <- random
  let initialisedGame = MkInProgressGameState
        { inProgressGrid         = emptyGrid
        , inProgressOnMove       = whichPlayer
        , inProgressFirstPlayer  = firstPlayer
        , inProgressSecondPlayer = MkPlayer secondPlayer secondToken
        }
  insertKVS gameId (InProgress initialisedGame)
  deleteKVS @GameId @Player gameId
  pure secondToken

As can be seen from presented and discussed so far, the example with the Maybe monad a similar Either err monad support a programming pattern where explicit mentioning of error cases can be avoided by using the monadic bind operator, or equivalently the do-notation. This idea applies to monads in general, including the Sem r monad from the Polysemy library. If we think of an error value resulting from throw, when or note in the finaliseNewGame function and denote it with mErr, any subsequent computation denoted by f will always give the same error value, i.e., (mErr >>= f) = mErr. This is what allows us to avoid explicit branching on error cases; error-checking is handled automatically by the (>>=) operator behind the curtains.

You might notice the resulting code looks very imperative! Do not be fooled as this is a pure functional program! Unlike a program in an imperative programming language, here we have a total function that truly reflects its computation in its type signature. One of the constraints on capabilities denoted by a polymorphic variable r in the return type Sem r PlayerToken is:

Member (Error FinaliseNewGameError) r

This means the function can throw errors of type FinaliseNewGameError. In other words, should an error occur at any point, the function will return a value of type FinaliseNewGameError. Therefore, the function does exactly what it says on the tin can: if successful, it will return a PlayerToken, and a FinaliseNewGameError otherwise. Note that it will be the web server later on that will turn either a PlayerToken or a FinaliseNewGameError to an HTTP response.

Propagating Error Values Through Levels of Abstraction

What has worked well for me in terms of propagating an error value from an innermost function outwards is building larger and larger wrapping error types. In other words, the innermost function can return a leaf error type, the next level wraps the leaf error in a new error type, and so on until the errors are handled.

In connect4, there is only one level of errors above the leaf level. The FinaliseNewGameError is wrapped at the top-most level like this:

-- | An umbrella application error data type that is a sum of all possible
-- errors that can be thrown in the 'LogicInterface' module.
data AppError
  = AppErrorNew NewGameError
  | AppErrorFinalise FinaliseNewGameError
  | AppErrorInsert DiscInsertionError
  | AppErrorStatus GameStatusError

Errors are promoted up the error level hierarchy via mapError from the Polysemy.Error module. The error mapping function has this signature:

mapError :: forall e1 e2 r a. Member (Error e2) r => (e1 -> e2) -> Sem (Error e1 ': r) a -> Sem r a 

In connect4, there are only two error levels so I map errors in an interpreter that runs the game logic for a handler given by the sem argument program:

-- | A natural transformation taking a computation from the 'Sem' monad to
-- the 'Handler' monad.
interpretServer
  :: Config
  -> IORef (M.Map GameId Player)
  -> IORef (M.Map GameId GameStatus)
  -> IORef R.StdGen
  -> Sem EffectList a
  -> Handler a
interpretServer conf playerMap statusMap gen sem =
  sem
    & runInputConst conf
    & runKVSInMemory playerMap
    & runKVSInMemory statusMap
    & mapError AppErrorNew
    & mapError AppErrorFinalise
    & mapError AppErrorInsert
    & mapError AppErrorStatus
    & runError @AppError
    & runRandom gen
    & runM
    & liftToHandler

liftToHandler :: IO (Either AppError a) -> Handler a
liftToHandler = Handler . ExceptT . fmap handleError

If your situation requires more error levels, you can apply the same technique and have your function support only one error type wrapping all error types from the level below, analogous to the AppError error type.

Handling errors

To handle errors, I simply pattern match on all possible errors and map it to a final 400 HTTP error response, which is in Servant given by the Servant.Server.ServerError type:

-- | The function converts an application error into a server error.
-- It returns a valid value intact.
handleError :: Either AppError a -> Either ServerError a
handleError = mapLeft go where
  go :: AppError -> ServerError
  go (AppErrorNew      e) = handleNew e
  go (AppErrorFinalise e) = handleFinalise e
  go (AppErrorInsert   e) = handleInsert e
  go (AppErrorStatus   e) = handleStatus e

-- ...

  handleFinalise :: FinaliseNewGameError -> ServerError
  handleFinalise FinaliseEmptyPlayerName =
    err400 { errBody = toBS "The second player's name cannot be empty!" }
  handleFinalise (FinaliseUnknownGame gameId) = err409
    { errBody = toBS
      (unwords ["There is no game with an ID", pack (getGameId gameId) <> "."])
    }
  handleFinalise (FinaliseTakenName name gameId) = err409
    { errBody = toBS
                  (unwords
                    [ "It is not possible to finalise a game with an ID"
                    , pack (getGameId gameId)
                    , "because the first player is already using the name"
                    , pack (getPlayerName name) <> "."
                    ]
                  )
    }

Conclusion

Errors are values too, just like values we get on a happy path. They can be communicated via types. The type system can check if errors are declared in a function signature, and let us know they have to be handled when not declared in the signature. By putting errors in the signature, we communicate to others and our future selves reading and reusing our code that a computation can fail, which facilitates reusability and abstraction: they do not have to go down a rabbit hole to find out five levels deep into our abstraction that a function can fail.


  1. While Yesod might be doing an incredible job in using types when compared to web frameworks from other programming languages, these days there are Haskell web frameworks that make a far greater use of the type system, and thereby make writing web services less error-prone.↩︎