railroad: Railway-oriented error handling: unwrap, throw, recover or fall back

[ bsd3, control, error-handling, library ] [ Propose Tags ] [ Report a vulnerability ]

Railway-oriented error handling for Haskell: one short operator per step, and the failure paths stop taking up lines.

Each operator looks at a result (Maybe, Either, Bool, Validation, or a collection of them), decides whether it is a failure, and says what to do about it. Where you would write

rows <- runQuery (userById uid) >>= either (const (throwError err503)) pure
user <- case rows of
  []  -> throwError err404
  [u] -> pure u
  _   -> throwError err500

you write

user <- runQuery (userById uid) ? err503 ?! cardinalityErr err404 (const err500)

Layers are unwrapped left to right: ? err503 unwraps the database Either, then ?! demands exactly one row (none is a 404, several a 500).

What to do about a failure:

  • Throw: ? (constant error) and ?? (error computed from the failure).

  • Recover: ?~ and ??~.

  • Observe, then carry on: ?<*, as in runQuery q ?<* logDbError ? err503.

  • Fall back to another source: ?|, or ?|<> to combine the errors.

What counts as a failure:

  • Nothing, Left, False and Failure. A collection fails if any element does, so an empty collection passes. Validation failures accumulate, so a whole request can report every problem at once.

  • Collections are also checked by size: ?+ (at least one), ?! (exactly one), ?∅ (none), and ?∃ (at least one element succeeded; keeps the successes).

  • Classifiers: ?> tags by a predicate. Any function into Either, Maybe, Bool or Validation is a classifier, applied with <&>. Railroad.Bifurcate provides neg, atLeastOne, exactlyOne, noneAtAll and anySucceeds.

Works in any MonadError (Railroad.MonadError: mtl, ExceptT, servant's Handler) and in effectful with the Error effect (Railroad). All operators are infixl 1 like >>= and <&>, so they chain without parentheses. The readme has more examples.


[Skip to Readme]

Downloads

Note: This package has metadata revisions in the cabal description newer than included in the tarball. To unpack the package including the revisions, use 'cabal get'.

Maintainer's Corner

Package maintainers

For package maintainers and hackage trustees

Candidates

Versions [RSS] 0.1.0.0, 0.1.0.1, 0.1.1.0, 0.1.1.1, 0.1.1.2, 0.1.1.3, 0.1.1.4, 0.1.1.5, 0.1.2.0, 0.1.2.1, 0.2.0.0, 0.2.0.1, 0.2.1.0, 0.3.0.0
Change log CHANGELOG.md
Dependencies base (>=4.17 && <4.23), effectful (>=2.5 && <2.8), mtl (>=2.2 && <2.4), validation (>=1.1 && <1.3 || >=1.3.1 && <1.4) [details]
Tested with ghc ==9.10.3, ghc ==9.12.4, ghc ==9.14.1
License BSD-3-Clause
Author Frederik Kallstrup Mastratisi
Maintainer mastratisi@proton.me
Uploaded by mastratisi at 2026-10-13T08:48:26Z
Revised Revision 1 made by mastratisi at 2026-10-13T08:56:34Z
Category Control, Error Handling
Home page https://github.com/mastratisi/railroad
Bug tracker https://github.com/mastratisi/railroad/issues
Source repo head: git clone https://github.com/mastratisi/railroad
Distributions
Downloads 136 total (30 in the last 30 days)
Rating (no votes yet) [estimated by Bayesian average]
Your Rating
  • λ
  • λ
  • λ
Status Docs uploaded by user
Build status unknown [no reports yet]

Readme for railroad-0.3.0.0

[back to package description]

railroad

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

Haskell License

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.

Contact

DM me on https://x.com/mastratisi97 . I like to hear if other people find this useful.