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 ()
= do
prog <- getLine
line 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 ()
= sequence_ (replicate 5 prog) loop
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)
= readMaybe <$> getLine
maybeInt
loopMaybe :: IO ()
= sequence_ (replicate 5 processNum) where
loopMaybe processNum :: IO ()
= do
processNum <- maybeInt
mNum 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
-> True) _ =
finaliseNewGame (isNameEmpty FinaliseEmptyPlayerName
throw = do
finaliseNewGame secondPlayer' gameId <- getKVS @GameId @Player gameId
mGame 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
<- random
secondToken <- random
whichPlayer let initialisedGame = MkInProgressGameState
= emptyGrid
{ inProgressGrid = whichPlayer
, inProgressOnMove = firstPlayer
, inProgressFirstPlayer = MkPlayer secondPlayer secondToken
, inProgressSecondPlayer
}InProgress initialisedGame)
insertKVS gameId (@GameId @Player gameId
deleteKVS 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
-> True) _ =
finaliseNewGame (isNameEmpty FinaliseEmptyPlayerName
throw = do
finaliseNewGame secondPlayer' gameId <- getKVS @GameId @Player gameId
mGame <- note (FinaliseUnknownGame gameId) mGame
firstPlayer let secondPlayer = dropWhitespace secondPlayer'
== secondPlayer)
when (playerName firstPlayer . void . throw $ FinaliseTakenName secondPlayer gameId
<- random
secondToken <- random
whichPlayer let initialisedGame = MkInProgressGameState
= emptyGrid
{ inProgressGrid = whichPlayer
, inProgressOnMove = firstPlayer
, inProgressFirstPlayer = MkPlayer secondPlayer secondToken
, inProgressSecondPlayer
}InProgress initialisedGame)
insertKVS gameId (@GameId @Player gameId
deleteKVS 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
= Handler . ExceptT . fmap handleError liftToHandler
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
= mapLeft go where
handleError go :: AppError -> ServerError
AppErrorNew e) = handleNew e
go (AppErrorFinalise e) = handleFinalise e
go (AppErrorInsert e) = handleInsert e
go (AppErrorStatus e) = handleStatus e
go (
-- ...
handleFinalise :: FinaliseNewGameError -> ServerError
FinaliseEmptyPlayerName =
handleFinalise = toBS "The second player's name cannot be empty!" }
err400 { errBody FinaliseUnknownGame gameId) = err409
handleFinalise (= toBS
{ errBody unwords ["There is no game with an ID", pack (getGameId gameId) <> "."])
(
}FinaliseTakenName name gameId) = err409
handleFinalise (= toBS
{ errBody 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.
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.↩︎