Skip to content
AgileLogics
7 min read

Error Handling

Prerequisites: Maybe and Either

Haskell has two kinds of error handling, and good code keeps them separate:

  1. Expected failures in pure code — invalid input, a missing key. Represent them in the type with Maybe or Either.
  2. Exceptions in IO — a missing file, a network failure. These are thrown and caught with Control.Exception.

Pure errors: Either all the way

data OrderError = EmptyCart | InvalidQuantity String Int | UnknownItem String
  deriving (Show)

prices :: [(String, Double)]
prices = [("tea", 3.5), ("cake", 4.0)]

lineTotal :: (String, Int) -> Either OrderError Double
lineTotal (item, qty)
  | qty <= 0 = Left (InvalidQuantity item qty)
  | otherwise = case lookup item prices of
      Nothing -> Left (UnknownItem item)
      Just p -> Right (p * fromIntegral qty)

orderTotal :: [(String, Int)] -> Either OrderError Double
orderTotal [] = Left EmptyCart
orderTotal items = sum <$> traverse lineTotal items

main :: IO ()
main = do
  print (orderTotal [("tea", 2), ("cake", 1)])
  print (orderTotal [("tea", 2), ("coffee", 1)])
  print (orderTotal [("tea", 0)])
  print (orderTotal [])
Right 11.0
Left (UnknownItem "coffee")
Left (InvalidQuantity "tea" 0)
Left EmptyCart

traverse runs lineTotal on every item and stops at the first Left.

Exceptions in IO

import Control.Exception
import System.IO.Error (isDoesNotExistError)

main :: IO ()
main = do
  result <- try (readFile "missing.txt") :: IO (Either IOException String)
  case result of
    Left e -> putStrLn ("Could not read file: " ++ (if isDoesNotExistError e then "it does not exist" else show e))
    Right text -> putStrLn text

  r <- try (evaluate (1 `div` (0 :: Int)))
  case r of
    Left DivideByZero -> putStrLn "caught division by zero"
    Left e -> putStrLn ("other arithmetic error: " ++ show e)
    Right v -> print v

  handle (\(ErrorCall msg) -> putStrLn ("caught error: " ++ msg)) $ do
    _ <- evaluate (length [1, 2, error "never evaluated"] + error "boom")
    return ()
Could not read file: it does not exist
caught division by zero
caught error: boom
  • try action returns Left exception or Right result. The type annotation chooses which exception type to catch.
  • evaluate forces a pure value inside IO, so an exception from it is raised there — laziness otherwise delays errors until the value is used, possibly outside your handler.
  • handle (and catch, with arguments the other way round) runs a handler if the action throws.

Throwing your own exceptions

import Control.Exception

data ConfigError = MissingKey String
  deriving (Show)

instance Exception ConfigError

getSetting :: String -> [(String, String)] -> IO String
getSetting key cfg = case lookup key cfg of
  Just v -> return v
  Nothing -> throwIO (MissingKey key)

main :: IO ()
main = do
  let cfg = [("host", "localhost")]
  getSetting "host" cfg >>= putStrLn
  r <- try (getSetting "port" cfg)
  case r of
    Left (MissingKey k) -> putStrLn ("missing setting: " ++ k)
    Right v -> putStrLn v
  (putStrLn "working..." >> throwIO (MissingKey "db")) `catch` \e ->
    putStrLn ("handled " ++ show (e :: ConfigError))
  putStrLn "done" `finally` putStrLn "cleanup always runs"
localhost
missing setting: port
working...
handled MissingKey "db"
done
cleanup always runs

Guidelines

  • Use Maybe/Either for failures callers are expected to handle.
  • Use throwIO in IO code for truly exceptional situations, and catch them near the top of the program.
  • Avoid error and partial functions (head, fromJust, read) in library code: they crash with little context.
  • Use bracket or finally (or withFile) to release resources even when an exception occurs.