5 min read
On this page

Maybe and Either

Most of the failure handling you do in Haskell happens through ordinary types, not exceptions. A function that might fail returns its possible-failure in the type signature. The compiler then forces every caller to deal with it. There is no "but I forgot to check for null" path; you cannot get to the value without acknowledging the failure.

Two types do most of the work: Maybe for "might fail with no information needed" and Either e for "might fail with a reason". Their monad instances let you chain operations and short-circuit on the first failure. This is the bread and butter of error-handling in Haskell, and it is the right tool for expected errors — the ones you can name in advance.

Maybe: failure without information

data Maybe a = Nothing | Just a

You use Maybe when failure is one bit of information. Lookups, parses, partial functions converted to total ones:

import qualified Data.Map.Strict as Map
import Text.Read (readMaybe)

userAge :: Map.Map String Int -> String -> Maybe Int
userAge m name = Map.lookup name m

parseInt :: String -> Maybe Int
parseInt = readMaybe

safeHead :: [a] -> Maybe a
safeHead []    = Nothing
safeHead (x:_) = Just x

Each of these has only one way to fail: the key was missing, the string was not a number, the list was empty. There is nothing else useful to say. Returning Nothing is enough.

Chaining with do-notation

When you need several fallible steps in sequence, the monad instance shines:

greetUser :: String -> Maybe String
greetUser input = do
  email <- parseEmail input
  user  <- lookupUser email
  pure ("Hello, " ++ name user)

If any step returns Nothing, the whole expression is Nothing and later steps are skipped. The same logic with explicit case expressions:

greetUser input =
  case parseEmail input of
    Nothing    -> Nothing
    Just email -> case lookupUser email of
      Nothing   -> Nothing
      Just user -> Just ("Hello, " ++ name user)

Once you have three or four steps, do-notation is unmissable.

Useful helpers

import Data.Maybe

fromMaybe   :: a -> Maybe a -> a            -- default value
maybe       :: b -> (a -> b) -> Maybe a -> b -- fold
isJust      :: Maybe a -> Bool
isNothing   :: Maybe a -> Bool
catMaybes   :: [Maybe a] -> [a]              -- drop Nothings
mapMaybe    :: (a -> Maybe b) -> [a] -> [b]  -- map and drop

mapMaybe is one of the most underrated functions in the prelude. "Try to convert each element; keep only the ones that worked":

ghci> mapMaybe parseInt ["1", "x", "3", "y", "5"]
[1, 3, 5]

Either: failure with a reason

data Either a b = Left a | Right b

Convention: Right is success, Left is the error. When you need to tell the caller why something failed, use Either:

data ParseError
  = EmptyInput
  | TooLong Int
  | InvalidChar Char
  deriving Show

parseUsername :: String -> Either ParseError String
parseUsername "" = Left EmptyInput
parseUsername s
  | length s > 32 = Left (TooLong (length s))
  | otherwise = case filter (not . isAlphaNum) s of
      (c:_) -> Left (InvalidChar c)
      []    -> Right s

The monad instance short-circuits on the first Left:

processSignup :: String -> String -> Either ParseError Account
processSignup nameInput emailInput = do
  username <- parseUsername nameInput
  email    <- parseEmail emailInput
  age      <- requireAge nameInput  -- fails with its own error
  pure (Account username email age)

If parseUsername returns Left EmptyInput, the whole expression is Left EmptyInput and parseEmail is never called. If you want to accumulate errors instead — collect every problem with the input at once — Either is the wrong type and Validation is right. That is the third file in this topic.

Using Either as a domain error type

A pattern you see in most production codebases:

data AppError
  = NotFound EntityType EntityId
  | Unauthorized
  | InvalidInput Text
  | DownstreamFailure ServiceName Text
  deriving (Show)

type AppM = ExceptT AppError IO   -- or whatever the app monad is

Functions return AppM Result and use throwError to emit failures. This gives you typed, exhaustively-handled errors. Hasura's GraphQL engine, persistent's database queries, servant's handlers all follow some variant of this shape.

ExceptT: Either over another monad

What about IO (Either e a)? You see this constantly: a database call that returns Right Row on success and Left DbError on failure. Chaining them with do-notation in IO still requires inspecting each Either:

loadProfile :: UserId -> IO (Either AppError Profile)
loadProfile uid = do
  userResult <- fetchUser uid
  case userResult of
    Left e -> pure (Left e)
    Right user -> do
      prefsResult <- fetchPrefs user
      case prefsResult of
        Left e -> pure (Left e)
        Right prefs -> pure (Right (buildProfile user prefs))

ExceptT collapses this:

import Control.Monad.Except

loadProfile :: UserId -> ExceptT AppError IO Profile
loadProfile uid = do
  user  <- ExceptT (fetchUser uid)
  prefs <- ExceptT (fetchPrefs user)
  pure (buildProfile user prefs)

Same logic, no manual case. ExceptT e m a is a newtype wrapper around m (Either e a) with a Monad instance that short-circuits on Left. Run it with runExceptT:

main :: IO ()
main = do
  result <- runExceptT (loadProfile uid)
  either handleError displayProfile result

ExceptT works with any base monad, not just IO. It is a transformer; if you are going down the MTL or ReaderT route from the previous topic, ExceptT (or a MonadError capability in an effect system) is how you compose it with the rest.

NonEmpty: encoding "at least one"

A value of type [a] might be empty. Sometimes that is wrong by construction — a list of "errors that occurred" is meaningless if it has zero elements. Data.List.NonEmpty.NonEmpty is the type for "at least one":

data NonEmpty a = a :| [a]

The first element is required; the rest is a regular list. Constructing one:

import Data.List.NonEmpty (NonEmpty(..))
import qualified Data.List.NonEmpty as NE

errs :: NonEmpty String
errs = "first" :| ["second", "third"]

NE.toList, NE.head, NE.tail, NE.fromList (partial — throws on empty) round out the API. Use NonEmpty whenever your data semantically excludes empty. The validation library uses NonEmpty to ensure a Failure always carries at least one error.

When to use which

  • Maybe: failure with no info needed. Lookups, parses, partial-to-total conversions.
  • Either e: expected, named errors with information. The bulk of domain errors in production code.
  • Validation e: when you need to accumulate multiple errors instead of stopping at the first.
  • NonEmpty a: any time "list with at least one" is the actual constraint.
  • Exceptions: programmer errors, resource exhaustion, async cancellation. The next file covers these.

The convention is: expected, recoverable errors go in the type system (Either, Maybe, custom error types). Truly exceptional conditions (out of memory, the database connection died, divide-by-zero in pure code) become Haskell exceptions.

This split is not always clean — network IO errors arrive as exceptions even though they are completely expected from the application's perspective. The standard fix is to catch them at the boundary and convert to Either-shaped failures the rest of your code expects.

Putting it together

Here is a small but realistic example: validate input, look up a user, charge an account.

{-# LANGUAGE OverloadedStrings #-}

import Control.Monad.Except
import qualified Data.Text as T

data AppError
  = BadEmail T.Text
  | UserNotFound T.Text
  | InsufficientFunds Money
  deriving Show

parseEmail :: T.Text -> Either AppError T.Text
parseEmail e
  | T.any (== '@') e = Right e
  | otherwise        = Left (BadEmail e)

findUser :: T.Text -> ExceptT AppError IO User

debit :: User -> Money -> ExceptT AppError IO ()

charge :: T.Text -> Money -> ExceptT AppError IO ()
charge rawEmail amount = do
  email <- liftEither (parseEmail rawEmail)
  user  <- findUser email
  debit user amount

main :: IO ()
main = do
  result <- runExceptT (charge "alice@example.com" 50)
  case result of
    Left err -> putStrLn ("failed: " ++ show err)
    Right () -> putStrLn "ok"

The shape: each step that can fail returns Either AppError or ExceptT AppError IO. liftEither lifts a pure Either into the transformer. The do-block reads top to bottom and short-circuits on the first failure with no boilerplate.

Common pitfalls

Returning Maybe when you should return Either. If "the function failed" is enough, Maybe. If "the function failed because X" matters, Either. Refactoring Maybe into Either later is annoying because every call site changes.

Using String for error types. A typed error sum (data MyError = ... | ...) is searchable, exhaustive, refactorable. A String error is none of those. Reach for typed errors in any codebase you expect to maintain.

Forgetting that Either short-circuits. New users assume the monad accumulates. It does not. Validation does.

Throwing exceptions for ordinary control flow. error and throw are not for "the user typed something invalid". They are for unrecoverable problems. Use Either (or Validation).

Using head and fromJust. Both are partial functions that crash. Replace with safeHead, listToMaybe, maybe, or destructure explicitly.

Mixing Either e1 and Either e2 in one do-block. They are different monads. Either pick one error type for the whole block, or use withExceptT / first to convert.

Building a tower of IO (Either (Either e1) e2). That nesting is a hint to introduce ExceptT or move to a unified error type.

Key takeaways

  • Maybe for failure without information, Either e for failure with a reason. Both have lawful Monad instances and short-circuit on the first failure.
  • Do-notation collapses nested case-on-failure into linear, readable code.
  • The convention: expected, recoverable errors live in types (Either, custom error sums); exceptional conditions become exceptions.
  • ExceptT e m lifts Either-shaped failure into any base monad — usually IO, sometimes a transformer stack.
  • NonEmpty is the right type when "at least one" is part of the contract.
  • Define a typed error sum for your domain. String-as-error scales badly and loses information.
  • Either's short-circuit semantics are usually what you want; when you want error accumulation, reach for Validation.