Error handling in Haskell differs significantly from exception-based approaches in imperative languages. Haskell provides several approaches that align with its functional nature and type system.

Types of Errors in Haskell

  1. Compile-time errors: Type errors, syntax errors, etc.
  2. Runtime errors: Exceptions that occur during program execution
  3. Expected failures: Situations where operations might legitimately fail

This page focuses on handling the last two categories.

Approaches to Error Handling

1. Maybe Type

The Maybe type represents computations that might fail:

data Maybe a = Nothing | Just a

Use Cases

  • Partial functions (not defined for all inputs)
  • Functions that could fail without needing detailed error information

Example

safeDivide :: Int -> Int -> Maybe Int
safeDivide _ 0 = Nothing
safeDivide x y = Just (x `div` y)
 
-- Usage
case safeDivide 10 0 of
  Nothing -> putStrLn "Division by zero!"
  Just result -> putStrLn $ "Result: " ++ show result

Working with Multiple Maybe Values

-- Using do notation
computeAverage :: [Int] -> Maybe Double
computeAverage xs = do
  if null xs
    then Nothing
    else Just (fromIntegral (sum xs) / fromIntegral (length xs))
 
-- Using monadic functions
findUserAvgScore :: UserId -> [ScoreId] -> Maybe Double
findUserAvgScore uid scoreIds = do
  user <- findUser uid
  scores <- mapM (getScore user) scoreIds
  return (average scores)

2. Either Type

The Either type provides more detailed error information:

data Either a b = Left a | Right b

By convention, Left contains error information and Right contains successful results.

Use Cases

  • When you need specific error information
  • Validation that needs to report why it failed
  • Functions with multiple failure modes

Example

data DivideError = DivideByZero | OverflowError
 
safeDivide :: Int -> Int -> Either DivideError Int
safeDivide _ 0 = Left DivideByZero
safeDivide x y
  | x > maxBound `div` y = Left OverflowError
  | otherwise = Right (x `div` y)
 
-- Usage
case safeDivide 10 0 of
  Left DivideByZero -> putStrLn "Cannot divide by zero!"
  Left OverflowError -> putStrLn "Overflow would occur!"
  Right result -> putStrLn $ "Result: " ++ show result

Working with Multiple Either Values

-- Using do notation
validatePerson :: String -> Int -> Either String Person
validatePerson name age = do
  validName <- validateName name
  validAge <- validateAge age
  Right (Person validName validAge)
  where
    validateName "" = Left "Name cannot be empty"
    validateName n = Right n
    validateAge a
      | a < 0 = Left "Age cannot be negative"
      | a > 120 = Left "Age is unrealistic"
      | otherwise = Right a

3. ExceptT Monad Transformer

ExceptT combines the Either type with another monad (often IO):

newtype ExceptT e m a = ExceptT { runExceptT :: m (Either e a) }

Use Cases

  • Handling errors in code that also performs IO or other monadic operations
  • Creating a clean separation of error handling from other effects
  • Building complex functions that can fail at multiple points

Example

import Control.Monad.Except
 
type AppError = String
type App a = ExceptT AppError IO a
 
readConfig :: FilePath -> App Config
readConfig path = do
  exists <- liftIO $ doesFileExist path
  unless exists $ 
    throwError $ "Config file not found: " ++ path
  content <- liftIO $ readFile path
  case parseConfig content of
    Nothing -> throwError "Invalid config format"
    Just config -> return config
 
runApp :: App a -> IO (Either AppError a)
runApp = runExceptT

4. Runtime Exceptions

Haskell has a system for runtime exceptions, but it’s generally avoided in favor of explicit error types:

-- Generating exceptions
error :: String -> a
undefined :: a
 
-- Catching exceptions
catch :: Exception e => IO a -> (e -> IO a) -> IO a
try :: Exception e => IO a -> IO (Either e a)

Example

import Control.Exception
 
-- Catching exceptions
readFileContent :: FilePath -> IO String
readFileContent path = readFile path `catch` handleError
  where
    handleError :: IOException -> IO String
    handleError e = return $ "Error reading file: " ++ show e
 
-- Using try
readFileContent' :: FilePath -> IO (Either IOException String)
readFileContent' path = try (readFile path)

5. Custom Exceptions

You can define custom exception types:

import Control.Exception
 
data AppException = ConfigError String
                  | DatabaseError String
                  | NetworkError String
                  deriving (Show, Typeable)
 
instance Exception AppException
 
-- Throwing custom exceptions
throwConfigError :: String -> IO a
throwConfigError msg = throwIO (ConfigError msg)
 
-- Catching specific exception types
catchAppExceptions :: IO a -> IO a
catchAppExceptions action = action `catches` 
  [ Handler (\(e :: ConfigError) -> handleConfigError e)
  , Handler (\(e :: DatabaseError) -> handleDatabaseError e)
  , Handler (\(e :: SomeException) -> handleOtherExceptions e)
  ]

Validation: Collecting Multiple Errors

Sometimes you want to report all errors rather than just the first one:

import Data.Validation
 
data ValidationError = NameTooShort
                     | AgeTooLow
                     | InvalidEmail
                     deriving (Show)
 
validatePerson :: String -> Int -> String -> Validation [ValidationError] Person
validatePerson name age email =
  Person <$> validateName name <*> validateAge age <*> validateEmail email
  where
    validateName n
      | length n < 2 = Failure [NameTooShort]
      | otherwise = Success n
      
    validateAge a
      | a < 18 = Failure [AgeTooLow]
      | otherwise = Success a
      
    validateEmail e
      | not (isValidEmail e) = Failure [InvalidEmail]
      | otherwise = Success e

Error Handling Patterns

Defensive Programming

processData :: Maybe Data -> Either Error Result
processData Nothing = Left (MissingData "No data provided")
processData (Just d)
  | isValid d = Right (computeResult d)
  | otherwise = Left (InvalidData "Data is invalid")

Railway-Oriented Programming

Thinking of functions as “tracks” where success follows one track and failure another:

validateInput :: Input -> Either Error ValidInput
transformData :: ValidInput -> Either Error TransformedData
computeOutput :: TransformedData -> Either Error Output
 
processInput :: Input -> Either Error Output
processInput input = do
  validInput <- validateInput input
  transformedData <- transformData validInput
  computeOutput transformedData

Error Handling with Monads

Using monadic functions for cleaner error handling:

-- mapM for processing lists with potential failures
processItems :: [Item] -> Either Error [Result]
processItems = mapM processItem
 
-- filterM for filtering with side effects
getValidItems :: [Item] -> IO [Item]
getValidItems = filterM isValid
 
-- Using forM_ for actions that might fail
processAllItems :: [Item] -> Either Error ()
processAllItems items = forM_ items $ \item -> do
  result <- processItem item
  storeResult result

Best Practices

  1. Use the simplest mechanism that meets your needs:

    • For simple absence/presence of a value, use Maybe
    • For error details, use Either
    • For more complex requirements, consider monad transformers
  2. Make error types informative:

    data UserError = UserNotFound UserId
                   | InvalidPassword
                   | AccountLocked DateTime
  3. Return errors, don’t throw exceptions in pure code:

    -- Good
    lookup :: Key -> Map Key Value -> Maybe Value
     
    -- Avoid
    lookup :: Key -> Map Key Value -> Value
    lookup k m = case Map.lookup k m of
      Just v  -> v
      Nothing -> error "Key not found"
  4. Handle all cases explicitly:

    processResult :: Either Error Value -> String
    processResult (Right value) = "Success: " ++ show value
    processResult (Left (InputError msg)) = "Input error: " ++ msg
    processResult (Left (SystemError code)) = "System error: " ++ show code
    processResult (Left (OtherError e)) = "Other error: " ++ show e
  5. Compose error-handling functions using monads:

    validateAndProcess :: Input -> Either Error Output
    validateAndProcess = validate >=> process >=> format

Key Points to Remember

  1. Haskell favors explicit error handling using types like Maybe and Either
  2. Exceptions in Haskell are primarily for exceptional conditions, not regular error handling
  3. Monadic composition makes it easy to chain operations that might fail
  4. Custom error types help make error handling more informative and type-safe
  5. For IO operations, consider using ExceptT to combine IO with structured error handling