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:
- Safety: Avoiding partial functions (like
headon an empty list) eliminates runtime crashes. - Composability: Pure error types work naturally with higher-order functions and monadic composition, making it easy to chain operations that may fail.
- Explicitness: The type signature tells you whether something can go wrong, without hidden surprises.
- Separation of concerns: Pure error handling stays pure, while
IOexceptions handle truly unpredictable external failures.
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:
maybe :: b -> (a -> b) -> Maybe a -> bβ default value and transformation function.fromMaybe :: a -> Maybe a -> aβ returns default ifNothing.isJust,isNothingβ predicate checks.mapMaybeβ filter and map simultaneously.
-- 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
- Use
Maybewhen failure is simple and the reason is obvious (e.g., lookup in a map). - Use
Eitherto provide meaningful error messages β this helps debugging and user feedback. - Prefer pure error handling (
Maybe/Either) over exceptions whenever the failure is part of the expected domain logic. - Use
ExceptTto weave error handling through complex monadic stacks, keeping the error type explicit. - Never use
fromJustor partial functions in production code β pattern match or use total alternatives. - Log and re-throw
IOexceptions only at the boundaries where you can properly handle them. - Keep error types small and specific β avoid a single mega-error type that loses information.
- Use
MonadErrortype class if you want to write code generic over the error handling mechanism.
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.