← Back to DevBytes

Error Handling Patterns in Haskell

Error Handling in Haskell: A Complete Developer Tutorial

What Is Error Handling in Haskell?

In Haskell, error handling refers to the strategies and abstractions used to represent, propagate, and recover from failures. Because Haskell is a purely functional language, side effects (including exceptions) are segregated into the IO monad. This gives rise to two broad classes of error handling: pure error handling using types like Maybe and Either, and impure exception handling inside IO.

The key idea is to make the possibility of failure explicit in the type system. Instead of crashing or relying on out-of-band exception mechanisms, a function that can fail returns a value that signals success or failure. This forces callers to deal with errors, leading to more robust, composable code.

Why It Matters

Error handling in Haskell is critical for several reasons:

Understanding when to use Maybe, Either, monad transformers like ExceptT, and IO exceptions is fundamental to writing reliable Haskell software.

Pure Error Handling with Maybe

πŸš€ Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Basic Usage

The Maybe a type represents a computation that either produces a value of type a (Just a) or no value at all (Nothing). It’s the simplest error handling pattern, used when the only information needed is whether the computation succeeded.

-- lookup in a list of pairs
type PhoneBook = [(String, String)]

findPhone :: String -> PhoneBook -> Maybe String
findPhone name [] = Nothing
findPhone name ((k,v):rest)
  | name == k  = Just v
  | otherwise  = findPhone name rest

-- usage
phone = findPhone "Alice" myBook
case phone of
  Just num -> putStrLn ("Alice's number: " ++ num)
  Nothing  -> putStrLn "Not found"

Common Combinators

Haskell provides several functions to work with Maybe without explicit pattern matching:

-- using maybe to provide a default message
displayPhone :: String -> PhoneBook -> String
displayPhone name book =
  maybe "Not found" (\num -> name ++ ": " ++ num)
    (findPhone name book)

-- fromMaybe with a default value
defaultNumber :: Maybe String -> String
defaultNumber = fromMaybe "555-0000"

Maybe as a Monad

Maybe is a monad, allowing sequencing of operations that may fail. If any step returns Nothing, the whole chain short-circuits.

-- safe division: returns Nothing for division by zero
safeDiv :: Int -> Int -> Maybe Int
safeDiv _ 0 = Nothing
safeDiv x y = Just (x `div` y)

-- chaining operations
compute :: Int -> Int -> Int -> Maybe Int
compute x y z = do
  a <- safeDiv x y
  b <- safeDiv a z
  Just (b + 1)

-- equivalent using >>=
compute' x y z =
  safeDiv x y >>= \a ->
  safeDiv a z >>= \b ->
  Just (b + 1)

Error Handling with Either

When You Need Error Information

Maybe only tells you β€œit failed.” Either e a provides an error value of type e on failure (Left e) and a successful value a (Right a). This is the workhorse for pure error handling in Haskell.

-- parsing an integer with error message
parseInt :: String -> Either String Int
parseInt s =
  case reads s of
    [(n, "")] -> Right n
    _         -> Left ("Could not parse: " ++ s)

-- usage
case parseInt "42" of
  Right n -> putStrLn ("Got number: " ++ show n)
  Left err -> putStrLn ("Error: " ++ err)

Combinators and Monad Instance

Like Maybe, Either e is a monad. The Left value short-circuits subsequent computations. The Either module provides utilities like either, lefts, rights, and isRight.

-- using the `either` function
report :: Either String Int -> String
report = either ("Error: " ++) (\n -> "Success: " ++ show n)

-- monadic chaining with Either
addNumbers :: String -> String -> Either String Int
addNumbers s1 s2 = do
  n1 <- parseInt s1
  n2 <- parseInt s2
  Right (n1 + n2)

-- result: Right 7
-- addNumbers "3" "4"
-- result: Left "Could not parse: four"
-- addNumbers "3" "four"

Accumulating Errors

Sometimes you want to collect all errors rather than stopping at the first one. The Validation type (from packages like validation) or Either with an applicative instance (via a Semigroup constraint) can do this. Here’s a simple illustration using a list of errors:

import Data.Validation

type Errors = [String]

validateUser :: String -> Int -> Validation Errors (String, Int)
validateUser name age
  | null name || age <= 0 = Failure ["Name empty or age non-positive"]
  | otherwise = Success (name, age)

-- combine multiple validations (collects errors)
validateAll :: [(String, Int)] -> Validation Errors [(String, Int)]
validateAll = traverse (\(n,a) -> validateUser n a)

Error Handling in Monad Stacks: ExceptT

Combining Errors with Other Effects

When you need to mix error handling with state, logging, or IO, the ExceptT e m a monad transformer is essential. It adds the ability to throw and catch errors to an underlying monad m.

import Control.Monad.Except

type AppM = ExceptT String IO

-- lift IO actions into AppM
fetchFromDB :: String -> AppM Int
fetchFromDB key = do
  -- imagine a real IO lookup
  result <- liftIO $ lookupDB key
  case result of
    Nothing -> throwError "Key not found"
    Just v  -> pure v

-- using catchError for recovery
safeFetch :: String -> AppM Int
safeFetch key =
  fetchFromDB key `catchError` (\err -> do
    liftIO $ putStrLn ("Warning: " ++ err ++ ", using default")
    pure 0
  )

Running and Unlifting

To run an ExceptT e m a computation, use runExceptT, which gives m (Either e a). You can then extract the result in the base monad.

main :: IO ()
main = do
  result <- runExceptT $ safeFetch "user:42"
  case result of
    Left err -> putStrLn ("Fatal error: " ++ err)
    Right val -> putStrLn ("Value: " ++ show val)

IO Exceptions

Handling Unpredictable Failures

Pure error types assume failures are predictable and part of the program's logic. But for things like file I/O, network failures, or asynchronous exceptions, Haskell provides IO exceptions. Use try, catch, and bracket from Control.Exception.

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

-- try returns an Either-like result
readFileSafe :: FilePath -> IO (Either IOError String)
readFileSafe path = try (readFile path)

-- handling specific errors
readConfig :: IO (Maybe String)
readConfig = do
  result <- try (readFile "config.yaml") :: IO (Either IOError String)
  case result of
    Right content -> pure (Just content)
    Left e
      | isDoesNotExistError e -> pure Nothing
      | otherwise -> throwIO e  -- re-throw unexpected errors

Resource Cleanup with bracket

The bracket pattern ensures a resource is acquired, used, and released even if an exception occurs. It’s the equivalent of try-finally in other languages.

import System.IO

processFile :: FilePath -> IO ()
processFile path =
  bracket
    (openFile path ReadMode)           -- acquire
    (hClose)                            -- release (always runs)
    (\h -> do                          -- use
      contents <- hGetContents h
      putStrLn (process contents)
    )

Custom Error Types and Best Practices

Designing Your Own Error Types

For larger applications, define a custom error type as a sum type. Derive Show, Eq, and possibly Exception if you need to throw it in IO.

data AppError
  = NotFound String
  | ValidationError [String]
  | DatabaseError String
  deriving (Show, Eq)

instance Exception AppError

-- usage in ExceptT
type App a = ExceptT AppError IO a

throwAppError :: AppError -> App a
throwAppError = throwError

Best Practices

Example: A Small Application Pattern

Here’s a skeleton showing how all pieces fit together in a realistic scenario: a web handler that parses input, queries a database, and returns a result, using ExceptT with a custom error type.

{-# LANGUAGE OverloadedStrings #-}
import Control.Monad.Except
import Data.Text (Text)
import qualified Data.Text as T

data APIError = BadRequest Text | InternalError Text deriving Show

type Handler = ExceptT APIError IO

-- parse input with Either-style errors, then lift into Handler
parseId :: Text -> Either APIError Int
parseId t =
  case reads (T.unpack t) of
    [(n, "")] | n > 0 -> Right n
    _ -> Left (BadRequest "Invalid ID")

-- DB lookup (simulated)
fetchRecord :: Int -> IO (Maybe String)
fetchRecord id = pure (Just ("Record " ++ show id)) -- stub

-- combined handler
getRecord :: Text -> Handler String
getRecord rawId = do
  id <- ExceptT $ pure (parseId rawId)
  record <- liftIO (fetchRecord id) >>= \case
    Nothing -> throwError (InternalError "Not found")
    Just r  -> pure r
  pure record

-- run handler and convert to HTTP response
runHandler :: Handler String -> IO String
runHandler action = do
  result <- runExceptT action
  case result of
    Left (BadRequest msg) -> pure ("400: " ++ show msg)
    Left (InternalError msg) -> pure ("500: " ++ show msg)
    Right val -> pure ("200: " ++ val)

Conclusion

Error handling in Haskell is a disciplined, type-driven practice that turns failures into first-class citizens. Starting from the simple Maybe for optional values, moving to Either for informative errors, and then scaling to monad transformers like ExceptT and IO exceptions, you have a powerful toolkit to build resilient software. The key takeaway is to make errors explicit, choose the right pattern for the job, and always prefer total functions over partial ones. With these patterns, your Haskell code becomes safer, more maintainable, and easier to reason about.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles