railroad
Railway-oriented error handling for Haskell. Unwrap a Maybe, Either, Bool or Validation, or throw a mapped error, with one short operator.

Instead of
rows <- runQuery (userById uid) >>= either (const (throwError err503)) pure
user <- case rows of
[] -> throwError err404
[u] -> pure u
_ -> throwError err500
write
user <- runQuery (userById uid) ? err503 ?! cardinalityErr err404 (const err500)
runQuery returns Eff es (Either DbError [row]). Each operator unwraps one
layer, left to right: ? err503 unwraps the database Either, then ?!
demands exactly one row (none is a 404, several a 500).
It works in any MonadError (mtl, ExceptT, servant's Handler) and in
effectful with the Error effect.
import Railroad.MonadError
import Control.Monad.Except
example :: Either String Int
example = runExcept $ do
x <- pure (Just 2) ? "Value missing" -- Just 2 ~> 2
y <- pure (Right (Just 1)) ? "Outer fail" ? "Inner fail" -- Right (Just 1) ~> Just 1 ~> 1
z <- pure [Just 4] ? "List failed" ?! const "Not one element" -- [Just 4] ~> [4] ~> 4
q <- pure Nothing ?~ 1 -- Nothing ~> 1
pure (x + y + z + q) -- Right 8
How it works
Every operator does two things: it decides whether a value is a failure, then it says what
to do about a failure. Operators read left to right, and each one unwraps one layer:
runQuery (userById uid) Eff es (Either DbError [User])
? err503 Left _ → throw err503 Right rows → rows :: [User]
?! cardinalityErr err404 (const err500)
[] → throw err404, several → throw err500
[u] → u :: User
What counts as a failure depends on the type: Nothing, Left, False or Failure. A
list fails if any element does. There are four things to do about a failure:
| Do |
Operators |
Example |
| throw an error |
? ?? |
action ? err503 |
| recover with a default |
?~ ??~ |
action ?~ 0 |
| run an effect, then carry on |
?<* |
action ?<* logIt ? err503 |
| try another source |
?| ?|<> |
cache k ?| db k ? NotFound |
The operators for collections (?+, ?!, ?∅, ?∃) decide by how many elements there
are or succeeded; the next sections go through them. The operators also read as logical
connectives and quantifiers (∧, ∨, ¬, ∀, ∃); that reading is in railroad.md.
Contents
Install
Hackage
The package is called railroad: https://hackage.haskell.org/package/railroad
Nix
An expression for ghcWithPackages:
railroad = pkgs.haskellPackages.callCabal2nix "railroad" (pkgs.fetchFromGitHub
{ owner = "mastratisi"
; repo = "railroad"
; rev = "master"
; sha256 = pkgs.lib.fakeSha256; # Nix prints the real hash on the first build
}) {};
Example: a servant backend
Handlers in a servant + effectful backend (adapted from a real one) are
chains of queries and checks where each failure is an HTTP status. The queries
return Eff es (Either DbError [row]).
getUser :: Error ServerError :> es => UserId -> Eff es UserDTO
getUser uid = do
user <- runQuery (userById uid) ? err503 ?! cardinalityErr err404 (const err500)
pure (toUserDTO user)
register :: Error ServerError :> es => Email -> Eff es Token
register email = do
runQuery (userByEmail email) ? err503 ?∅ const err409 -- must not exist yet
makeJWT email ? err500
updateAsset :: Error ServerError :> es => UserId -> Asset -> Eff es ()
updateAsset uid asset = do
-- `permitted` is an SQL exists: one row, so no empty-collection gotcha (see below)
_ <- runQuery (permitted uid asset) ? err500 ? err403 -- db error, then "not permitted"
...
The same handlers in plain Haskell:
getUser' uid = do
rows <- runQuery (userById uid) >>= either (const (throwError err503)) pure
user <- case rows of
[] -> throwError err404
[u] -> pure u
_ -> throwError err500
pure (toUserDTO user)
register' email = do
rows <- runQuery (userByEmail email) >>= either (const (throwError err503)) pure
unless (null rows) (throwError err409)
makeJWT email >>= either (const (throwError err500)) pure
updateAsset' uid asset = do
allowed <- runQuery (permitted uid asset) >>= either (const (throwError err500)) pure
unless (and allowed) (throwError err403)
...
The operators
| Role |
Operator |
What it does |
Example |
| Throw |
? |
Unwrap, or throw a constant |
action ? MyError |
|
?? |
Unwrap, or throw an error computed from the failure |
action ?? toMyError |
| Collections |
?+ |
Throw on an empty collection |
items ?+ NoResults |
|
?! |
Throw unless there is exactly one element |
items ?! fromCardinalityErr |
|
?∅ (alias ?@) |
Throw on a non-empty collection |
items ?∅ DuplicateFound |
|
?∃ |
Throw unless at least one element succeeded; keep the successes |
sources ?∃ toErr |
| Recover |
?~ |
Recover with a constant |
action ?~ defaultVal |
|
??~ |
Recover with a value computed from the failure |
action ??~ toDefault |
| Classify |
?> |
Tag by a predicate: a rejected value becomes the failure |
action ?> isGood ? NotGood |
| Observe |
?<* |
Run an effect on a failure, then carry on |
action ?<* logIt ? MyError |
| Fall back |
?| |
Try the next source on failure; keep the last error |
cache k ?| db k ? NotFound |
|
?|<> |
The same, combining all the errors with <> |
a ?|<> b ?? toErr |
All operators are infixl 1, like >>=, <&> and &, so they chain left to
right with them without parentheses: runQuery q ? err503 <&> toDTO. <$> binds
tighter (infixl 4), so f <$> action ? err applies f to the wrapped value before
it is unwrapped, and f receives the Either. Map the result with <&> after the
operator.
Classifiers
A classifier is any function whose result type the operators already understand:
Either e r, Maybe r, Bool, Validation e r, or a traversable of those. It
sorts a value into failure or success and says nothing about what to do with a
failure. In Either e r the failure carries the reason (e) and the success carries
the value you continue with (r). A Maybe loses the reason
and a Bool loses the value.
Existing functions are classifiers: lookup k, readMaybe, or a parser such as
parseAge :: String -> Either String Int. Apply one with <&> and finish with an
operator, which decides what happens on failure:
readBody <&> parseAge ?? BadRequest -- throw, built from the parse error
readBody <&> parseAge ??~ const 18 -- recover with a default
readBody <&> parseAge ?<* logIt ? BadAge -- log the parse error, then throw
The classifiers in the library are pure functions in Railroad.Bifurcate and need no
monad:
neg :: Bifurcate a => a -> Either (CRes a) (CErr a) -- swap success and failure
tagBy :: (a -> Bool) -> a -> Either a a -- Right when the predicate holds
atLeastOne :: Foldable t => t a -> Maybe (t a)
exactlyOne :: Foldable t => t a -> Either (CardinalityError (t a)) a
noneAtAll :: Foldable t => t a -> Either (t a) ()
successes :: (Foldable t, Bifurcate a) => t a -> [CRes a]
failures :: (Foldable t, Bifurcate a) => t a -> [CErr a]
anySucceeds :: (Foldable t, Bifurcate a) => t a -> Either [CErr a] [CRes a]
Any classifier combines with any of four verbs. Only the throw column has dedicated
operators (m is the action, n another source, e a constant error, f a mapper,
d a default, h an effect):
| Classifier |
throw |
recover |
observe |
fall back |
| structural (∀ on a collection) |
m ? e m ?? f |
m ?~ d m ??~ f |
m ?<* h |
m ?| n |
tagBy |
m ?> p ? e |
m ?> p ?~ d |
m ?> p ?<* h |
m ?> p ?| n |
atLeastOne |
m ?+ e |
m <&> atLeastOne ?~ d |
m <&> atLeastOne ?<* h |
m <&> atLeastOne ?| n |
exactlyOne |
m ?! f |
m <&> exactlyOne ??~ f |
m <&> exactlyOne ?<* h |
m <&> exactlyOne ?| n |
noneAtAll |
m ?∅ f |
m <&> noneAtAll ??~ f |
m <&> noneAtAll ?<* h |
m <&> noneAtAll ?| n |
anySucceeds |
m ?∃ f |
m <&> anySucceeds ??~ f |
m <&> anySucceeds ?<* h |
m <&> anySucceeds ?| n |
neg |
m <&> neg ? e |
m <&> neg ??~ f |
m <&> neg ?<* h |
m <&> neg ?| n |
A fall-back chain is finished with ? or ?? to turn a total failure into a throw. A
failure with several cases is m ?? \case .... Nothing covers a success that also
carries errors (These).
neg expresses "must not exist" on a single value: findByEmail e <&> neg ? err409
throws err409 when a user is found and passes on Nothing. ?∃ is "at least one
of these sources answered": it throws when all of them failed and returns the
successes otherwise.
Fallbacks
?| and ?|<> try one source after another and stop at the first success. The
sources may be different structures (a cache returning Maybe, a database
returning Either) with the same success type. The right-hand side only runs if
the left one failed. The result is bifurcatable, so a final ? or ?? turns a
total failure into a throw:
cache k ?| db k ?| legacy k ? NotFound
?|<> needs the sources to share an error type and combines all the errors with
<>, in source order. Map differing error types to a common one first:
(cache k <&> note [CacheMiss]) ?|<> (db k <&> first pure) ?? NotFound
Validation runs all independent checks and collects every failure. A fallback
chain succeeds on the first good answer.
Observing failures
? err503 throws the underlying failure away. ?<* runs an effect on the failure
first and passes the value through unchanged, so the next operator still sees it:
runQuery q ?<* logDbError ? err503
The handler takes the failure (CErr), returns m (), and runs only on a
failure. It never throws.
What counts as an error
The operators ?, ??, ?~, ??~, ?>, ?| and ?<* agree on what a failure
is, through the Bifurcate class:
| Type |
Failure |
Failure info (CErr) |
Success info (CRes) |
Bool |
False |
() |
() |
Maybe a |
Nothing |
() |
a |
Either e a |
Left |
e |
a |
Validation e a |
Failure |
e |
a |
t f |
any element fails |
CErr f |
t (CRes f) |
On a traversable of Bool, Maybe or Either the check stops at the first
failure. On t (Validation e a) the Semigroup on e collects all of them, so
pure (map validateFile files) ?? badRequest turns every invalid file into one 400.
An empty t f has no element to fail, so it succeeds (see the gotcha below).
The collection operators ?+, ?! and ?∅ look at the number of elements:
| Operator |
Succeeds when |
Failure info |
Success info |
?+ |
non-empty |
() |
t a |
?! |
exactly one element |
CardinalityError (t a) |
a |
?∅ |
empty |
t a |
() |
data CardinalityError ta = IsEmpty | TooMany ta
cardinalityErr :: e -> (ta -> e) -> CardinalityError ta -> e -- a fold for it
When an operator throws, the error mapping is applied to the failure info, the
result is thrown, and the monadic sequence stops. ?~ and ??~ never throw:
they put a default value on the happy path instead.
Gotcha: an empty collection passes
On a traversable, ? and ?? check that every element succeeds (∀). Over
an empty collection that holds vacuously, like and []:
pure ([] :: [Bool]) ? err403 -- succeeds, with []
In a permission check, "no rows" should mean "not permitted", so require at least
one element first, with ?+:
_ <- runQuery (permitted uid asset) ? err500 ?+ err403 ? err403
-- ^ db error ^ >= 1 row ^ all True
Each quantifier has an operator, and they chain:
| Operator |
Succeeds when |
Logic |
? / ?? on [Bool], [Maybe a] |
every element succeeds |
∀ x ∈ xs. succeeds x |
?+ |
there is at least one element |
∃ x ∈ xs |
?! |
there is exactly one element |
∃! x ∈ xs |
?∅ |
there is no element |
¬∃ x ∈ xs |
?∃ |
at least one element succeeds |
∃ x ∈ xs. succeeds x |
?+ then ? |
there is one, and every one succeeds |
(∃ x ∈ xs) ∧ (∀ x ∈ xs. succeeds x) |
Behavior under an evolving code base
The same operator works on Maybe a, Either e a and a list of either, so a
dependency can change from Maybe to Either without touching its callers.
examples/error-handling-styles writes eleven
handlers in three styles (railroad, plain case, and orThrow/expectOne
helpers) and applies nine changes to each. Cells are edit sites / lines touched.
| Change |
railroad |
helpers |
case |
Maybe a to Either CacheError a |
0 / 0 |
2 / 6 |
2 / 8 |
Maybe a to [Maybe a] (shards, first hit wins) |
3 / 5 |
3 / 6 |
3 / 5 |
Either DbError [User] to Either DbError (Maybe User) |
1 / 2 |
4 / 18 |
3 / 14 |
Either DbError [Bool] to Either DbError Bool (SELECT EXISTS) |
3 / 6 |
3 / 6 |
6 / 15 |
validateName: Validation to Either |
1 / 2 |
1 / 2 |
1 / 2 |
| database timeout becomes 504 |
4 / 10 |
9 / 19 |
8 / 16 |
| several grants per user: any granting row suffices |
3 / 6 |
4 / 7 |
3 / 12 |
| audit-log every duplicate registration |
1 / 2 |
1 / 2 |
1 / 2 |
| add a third fallback source |
1 / 1 |
1 / 6 |
2 / 11 |
| all nine |
17 / 34 |
28 / 72 |
29 / 85 |
Adding a source to a fallback chain is one line:
-- before
cache k ?| db k ? NotFound
-- after
cache k ?| db k ?| legacy k ? NotFound
In the case version it is another level of nesting around the existing branches.
The timeout change took four edits, one per ? Unavailable, because the error is a
literal at each call site; with ?? dbErr from the start it is one.
One change went wrong silently. validateName changed from Validation to
Either, and pure (map validateName names) ?? BadRequest kept compiling but
reported the first problem instead of all of them. ? over a collection chooses
between stopping at the first failure and collecting every one by the element
type. The other two styles stopped compiling. After changing a return type, check
the operators at its call sites: a list where there was one value changes what ?
means, and an empty list passes (see the gotcha above).
One LLM wrote the handlers. The test code base has eleven handlers
with nine changes done to it and only GHC 9.10.3 was used.
Exercises
examples/exercises teaches the operators by translation.
Each exercise is a function written with case and an empty stub. You fill in the
stub with the operators until a test that runs both on random inputs agrees. There
are 38 exercises in 13 lessons, with hints and an answer key.
cd examples/exercises && cabal test exercises
Modules
Railroad: the operators for the effectful Error effect.
Railroad.MonadError: the same operators for any MonadError.
Railroad.Bifurcate: everything that does not throw. The Bifurcate class, the classifiers
(neg, tagBy, successes, failures, anySucceeds, atLeastOne, exactlyOne,
noneAtAll), CardinalityError, and the operators ?>, ?~, ??~, ?|, ?|<>, ?<*.
The first two re-export it, so you rarely import it directly. The reasoning behind
the design is in railroad.md.
DM me on https://x.com/mastratisi97 . I like to hear if other people find this
useful.