Polyglot
Languages Haskell error handling
Haskell § error-handling

Error handling

Haskell’s error-handling story has two principal layers: value-level errors expressed through Maybe and Either (the conventional choice for ordinary domain failures), and exception-based errors through the Control.Exception machinery (for genuinely exceptional conditions and for IO-related failures). The two coexist, and choosing between them is one of the principal design decisions in any non-trivial Haskell program. Modern Haskell strongly favours value-level error handling for recoverable errors and reserves exceptions for cases where the alternative would be impractical.

This page covers the value-level error types, the exception machinery, the Control.Exception interface, the bracket and try patterns, and the conventional discipline.

Total functions

A total function is one that produces a value for every input. The conventional Haskell discipline is to write total functions whenever possible; partial functions (those that may fail to terminate or produce a runtime error on some inputs) are considered code smell.

The principal partial functions in base:

FunctionPartial because
head, tail, init, lastFails on []
(!!)Fails on out-of-bounds index
fromJustFails on Nothing
readFails on unparseable input
errorAlways fails
undefinedAlways fails
div, mod, quot, remFail on division by zero

Each has a total alternative:

PartialTotal
headData.Maybe.listToMaybe
tailData.List.uncons (returns Maybe)
(!!)Pattern matching, or Data.List.find
fromJustData.Maybe.fromMaybe, pattern match
readText.Read.readMaybe
div n 0Pattern match on the divisor

The conventional advice: prefer the total form. Linters (Hlint) and idiomatic libraries flag uses of partial functions; the discipline catches the bug at the source.

Maybe for absence

Maybe represents the absence of a value:

data Maybe a = Nothing | Just a

lookup :: Eq k => k -> [(k, v)] -> Maybe v
parseInt :: String -> Maybe Int
findUser :: Int -> Maybe User

The conventional uses are search and parse functions where the failure mode is “the input doesn’t match the criterion” without further detail.

Common patterns:

import Data.Maybe

-- Provide a default:
fromMaybe :: a -> Maybe a -> a
greeting = fromMaybe "stranger" lookupName

-- Map over the Maybe:
maybe :: b -> (a -> b) -> Maybe a -> b
display = maybe "no value" show maybeNumber

-- Filter and collect:
catMaybes :: [Maybe a] -> [a]
mapMaybe :: (a -> Maybe b) -> [a] -> [b]

The Maybe monad admits chaining fallible operations:

processInput :: String -> Maybe Result
processInput s = do
    parsed   <- parseInput s
    validated <- validateInput parsed
    enriched <- enrichWith parsed validated
    return (computeResult enriched)

The chain produces Nothing on the first failure; the rest is short-circuited. The treatment is in Monads.

Either for value-or-error

Either carries an error value:

data Either a b = Left a | Right b

By convention:

  • Left is the error case.
  • Right is the success case.

(Mnemonic: right is correct.)

The conventional use is for fallible operations where the failure mode warrants explanation:

parseConfig :: String -> Either ConfigError Config
loadFile :: FilePath -> IO (Either IOError Text)
processOrder :: Order -> Either ValidationError Result

The Either e monad admits chaining:

processOrder :: Input -> Either String Result
processOrder input = do
    raw      <- parseInput input
    valid    <- validateOrder raw
    enriched <- enrichOrderData valid
    return (computeResult enriched)

The chain produces “the first error” or the final result.

Common functions:

import Data.Either

either :: (a -> c) -> (b -> c) -> Either a b -> c
fromRight :: b -> Either a b -> b
isLeft :: Either a b -> Bool
isRight :: Either a b -> Bool
lefts :: [Either a b] -> [a]                -- collect all errors
rights :: [Either a b] -> [b]                -- collect all successes
partitionEithers :: [Either a b] -> ([a], [b])

For substantial applications, library types like Validation from the validation package admit accumulating errors (as opposed to short-circuiting on the first):

import Data.Validation

result :: Validation [String] Result
result = mkResult <$> validateName name <*> validateAge age
-- if both fail, the error list contains both messages

The Validation is an Applicative (not a Monad); the construction admits collecting all errors rather than just the first.

Exceptions

For non-recoverable failures and for IO-related errors that don’t admit value-level treatment, Haskell provides exceptions:

import Control.Exception

throwIO :: Exception e => e -> IO a
catch :: Exception e => IO a -> (e -> IO a) -> IO a
try :: Exception e => IO a -> IO (Either e a)
handle :: Exception e => (e -> IO a) -> IO a -> IO a
bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
finally :: IO a -> IO b -> IO a

The Exception class admits any type:

data MyException = MyException String
    deriving (Show)

instance Exception MyException

main = do
    throwIO (MyException "something went wrong")
        `catch` \(MyException msg) -> putStrLn ("Caught: " ++ msg)

The convention is to declare Exception as a class instance after the type, and for the type to be a Show instance for diagnostic output.

catch, handle, try

catch :: Exception e => IO a -> (e -> IO a) -> IO a
handle :: Exception e => (e -> IO a) -> IO a -> IO a   -- = flip catch
try :: Exception e => IO a -> IO (Either e a)

The three forms admit different syntactic positions:

result <- catch action handler
result <- handle handler action
result <- try action :: IO (Either SomeException Result)

try is the most flexible — it produces a Maybe-style result. catch is the conventional form for inline handling.

The type-driven aspect of catching: the compiler determines which exception type to catch based on the handler’s signature:

result <- try action :: IO (Either IOException Result)
-- catches IOException only

For catching anything, use SomeException:

result <- try action :: IO (Either SomeException Result)

The SomeException is the supertype of all exceptions; it admits catching everything.

bracket for resource management

The conventional pattern for resource acquisition with guaranteed cleanup:

bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
-- bracket acquire release action

main = bracket
    (openFile "input.txt" ReadMode)    -- acquire
    hClose                              -- release
    (\handle -> do                      -- action
        contents <- hGetContents handle
        process contents)

bracket runs the acquire, then the action, then the release — even if the action throws. The mechanism is the conventional Haskell idiom for resources that need explicit cleanup.

For one-off cleanup without a resource, finally:

finally :: IO a -> IO b -> IO a

main = do
    setUp
    doWork `finally` tearDown

finally runs the second action whether the first succeeds or fails.

For withFile, withSocketFD, and similar, the bracket-pattern is encapsulated:

import System.IO

main = withFile "input.txt" ReadMode $ \handle -> do
    contents <- hGetContents handle
    process contents
-- equivalent to: bracket (openFile ...) hClose (\h -> ...)

withFile is the conventional contemporary form for file operations.

Standard exception types

The base library defines several exception types:

TypeModuleUse
IOExceptionSystem.IO.ErrorI/O errors (file not found, permission denied)
ArithExceptionControl.Exception.BaseDivision by zero, overflow
ArrayExceptionControl.Exception.BaseOut-of-bounds array access
ErrorCallControl.ExceptionThe exception thrown by error
PatternMatchFailControl.ExceptionFailure of a pattern match
AsyncExceptionControl.ExceptionAsynchronous interruption
SomeExceptionControl.ExceptionSupertype of all exceptions

The IOException is by far the most common; it is thrown by file operations, network operations, and most other IO that may fail.

error and undefined

The error function throws an exception:

error :: String -> a

unsafeHead :: [a] -> a
unsafeHead []    = error "empty list"
unsafeHead (x:_) = x

undefined is the same as error "undefined":

undefined :: a

placeholder :: Int -> Int
placeholder = undefined         -- to be implemented

Both are sometimes useful — for placeholders during development, for “this case should never happen” assertions — but the conventional discipline is to avoid them in production code. They produce runtime errors rather than compile-time errors and are difficult to recover from.

The compiler-friendly alternatives:

  • errorWithoutStackTraceerror without a stack trace.
  • Data.Maybe.fromMaybe, Data.Either.either — for total handling.
  • Type-driven exhaustive pattern matching — let the compiler verify that all cases are handled.

Pattern-match failure

When a function’s pattern match fails (no equation matches), the runtime throws PatternMatchFail:

unsafeHead :: [a] -> a
unsafeHead (x:_) = x
-- unsafeHead []   throws PatternMatchFail

The -Wincomplete-patterns (part of -Wall) warns about this at compile time. The conventional discipline is to enable the warning and handle every case.

MonadFail

MonadFail is the class for monads that admit pattern-match failure:

class Monad m => MonadFail m where
    fail :: String -> m a

instance MonadFail Maybe where
    fail _ = Nothing

instance MonadFail [] where
    fail _ = []

instance MonadFail IO where
    fail = throwIO . userError

In do-notation, a non-trivial pattern failure invokes fail:

processList :: [Int] -> Maybe Int
processList xs = do
    Just n : _ <- pure (map (>= 0) xs)    -- pattern match
    return n
-- if the first element is False, fail is invoked, producing Nothing

The mechanism is occasionally surprising; the conventional discipline is to use explicit case for non-trivial patterns in do-notation.

The conventional discipline

The principal contemporary Haskell error-handling guidelines:

Prefer value-level error handling

-- For absence:
findUser :: UserId -> Maybe User

-- For value-or-error:
parseConfig :: String -> Either ConfigError Config

-- For accumulating errors:
validateForm :: Form -> Validation [Error] ValidForm

The mechanism is type-checked, exhaustive, and integrates with the rest of the language.

Use exceptions for IO and unexpected failures

-- File operations may throw IOException:
contents <- try (readFile "input.txt") :: IO (Either IOException String)

-- Network operations may throw various:
response <- try (http get "url") :: IO (Either HttpException Response)

The exception mechanism is appropriate for failures that are exceptional in the literal sense — failures the typical caller is not equipped to handle.

Use bracket for resource management

withDatabase $ \conn -> do
    rows <- query conn "SELECT * FROM users"
    process rows

The withResource pattern guarantees cleanup even on exception.

Avoid error and partial functions

-- Bad:
firstUser users = head users

-- Good:
firstUser users = listToMaybe users

Don’t catch SomeException indiscriminately

-- Bad: catches everything, including async exceptions
result <- try action :: IO (Either SomeException Result)

-- Good: catch the specific exception types
result <- try action :: IO (Either IOException Result)

Catching SomeException includes AsyncException (thread interruption), which the program almost never wants to handle. The conventional defence is to use specific exception types.

The safe-exceptions package provides a more principled wrapper that handles async exceptions correctly.

Common patterns

Validate-and-construct

data Email = Email String

mkEmail :: String -> Either String Email
mkEmail s
    | '@' `elem` s = Right (Email s)
    | otherwise    = Left "must contain @"

The pattern admits invariant-preserving construction; consumers cannot construct an Email without going through the validator (assuming the constructor is not exported).

Chain fallible operations

processInput :: String -> Either String Result
processInput input = do
    parsed   <- parseInput input
    valid    <- validateInput parsed
    enriched <- enrichWith parsed valid
    return (computeResult enriched)

The Either e monad short-circuits on the first failure.

Try-catch-finally

import Control.Exception

main = do
    result <- bracket
        (openFile "input.txt" ReadMode)
        hClose
        (\h -> do
            contents <- hGetContents h
            return (process contents))
            `catch` \(e :: IOException) -> do
                putStrLn ("Error: " ++ show e)
                return defaultResult
    print result

The combination of bracket and catch admits resource management with exception handling.

Convert between Maybe/Either and exceptions

-- Maybe to exception:
fromJustOrThrow :: Exception e => e -> Maybe a -> IO a
fromJustOrThrow ex Nothing  = throwIO ex
fromJustOrThrow _  (Just x) = return x

-- Either to exception:
fromEither :: Exception e => Either e a -> IO a
fromEither (Left e)  = throwIO e
fromEither (Right x) = return x

The functions admit converting value-level errors to exceptions when the calling context requires the exception form.

Maybe to default

import Data.Maybe (fromMaybe)

displayName user = fromMaybe "stranger" (userName user)

fromMaybe admits “use this default if absent”.

Either to logged failure

import Data.Either (either)

logResult :: Either Error Success -> IO ()
logResult = either (logError . show) (logSuccess . show)

The either function admits handling both cases without an explicit case.

A note on the broader landscape

The Haskell community has explored several alternatives to the conventional Maybe/Either/exception story:

  • Validation (validation package) — applicative validation that accumulates errors.
  • These (these package) — three-way “left or right or both” type.
  • MonadError (mtl package) — class abstraction over Either-like effects.
  • MonadThrow / MonadCatch (exceptions package) — class abstractions over exception-like effects.
  • safe-exceptions — a more principled wrapper that distinguishes synchronous from asynchronous exceptions.
  • Effect systems (polysemy, effectful) — first-class error effects with explicit handlers.

For most application code, Maybe, Either, and the standard exception machinery suffice; the more elaborate libraries are appropriate for codebases that need richer error semantics. The conventional contemporary advice is to start simple and adopt the more elaborate machinery only when the simpler tools become inadequate.