railroad 0.1.1.5 → 0.1.2.0
raw patch · 7 files changed
+426/−31 lines, 7 filesdep ~validationPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: validation
API changes (from Hackage documentation)
+ Railroad: (?|) :: (Monad m, Bifurcate a, Bifurcate b, CRes a ~ CRes b) => m a -> m b -> m (Either (CErr b) (CRes a))
+ Railroad: (?|<>) :: (Monad m, Bifurcate a, Bifurcate b, CRes a ~ CRes b, CErr a ~ CErr b, Semigroup (CErr a)) => m a -> m b -> m (Either (CErr a) (CRes a))
+ Railroad.MonadError: (?|) :: (Monad m, Bifurcate a, Bifurcate b, CRes a ~ CRes b) => m a -> m b -> m (Either (CErr b) (CRes a))
+ Railroad.MonadError: (?|<>) :: (Monad m, Bifurcate a, Bifurcate b, CRes a ~ CRes b, CErr a ~ CErr b, Semigroup (CErr a)) => m a -> m b -> m (Either (CErr a) (CRes a))
Files
- CHANGELOG.md +13/−0
- railroad.cabal +4/−3
- readme.md +247/−0
- src/Railroad.hs +51/−13
- src/Railroad/MonadError.hs +15/−15
- test/Railroad/MonadErrorSpec.hs +45/−0
- test/RailroadSpec.hs +51/−0
CHANGELOG.md view
@@ -1,5 +1,18 @@ # Revision history for railroad +## 0.1.2.0 -- 2026-10-11+* Add the fallback operator `(?|)`: runs the left action, and only if it fails runs the right one.+ The two sides can be different structures (`Maybe`, `Either`, ...) with the same success type, the+ last error is kept, and the result chains and is finished with `?` or `??`. It never throws, so+ `Railroad.MonadError` re-exports the one definition.+* Add `(?|<>)`, the same fallback but when every source fails the errors are combined with `<>`+ in source order. The sources must agree on the error type (`CErr`) and the success type (`CRes`).+* Documentation: a servant + effectful example and a section on the fallback operators in the README,+ clearer haddocks (module headers, `CErr`, `CRes`, each operator), and `readme.md` in `extra-doc-files`.+* Add `tested-with` (GHC 9.4.8 to 9.14.1, the versions the test suite is run on).+* Write the `validation` bound without a trailing zero or a strict lower bound:+ `(>= 1.1 && < 1.3) || (>= 1.3.1 && < 1.4)`, the same set of releases as in 0.1.1.5.+ ## 0.1.1.5 -- 2026-10-11 * Exclude `validation-1.3.0` from the bounds: it does not build (it uses `foldable1-classes-compat` without declaring it). `validation >= 1.3.1` needs `base >= 4.18`, so on GHC 9.4 the solver picked
railroad.cabal view
@@ -1,13 +1,14 @@ cabal-version: 3.0 name: railroad-version: 0.1.1.5+version: 0.1.2.0 license: BSD-3-Clause license-file: LICENSE author: Frederik Kallstrup Mastratisi maintainer: mastratisi@proton.me category: Control, Error Handling build-type: Simple-extra-doc-files: CHANGELOG.md+tested-with: GHC == 9.4.8, GHC == 9.6.7, GHC == 9.8.4, GHC == 9.10.3, GHC == 9.12.4, GHC == 9.14.1+extra-doc-files: CHANGELOG.md, readme.md synopsis: A railway oriented mini-DSL for unified error handling. description: A railway oriented mini-DSL for handling and interpreting error states @@ -24,7 +25,7 @@ base >=4.17 && < 4.23, effectful >= 2.5 && < 2.8, mtl >= 2.2 && < 2.4,- validation (>= 1.1 && < 1.3.0) || (> 1.3.0 && < 1.4)+ validation (>= 1.1 && < 1.3) || (>= 1.3.1 && < 1.4) library import: stuff
+ readme.md view
@@ -0,0 +1,247 @@+# railroad++**A terse Haskell railroad error handling DSL abstracting over error functors by catamorphism via `Either`.**++Instead of++```haskell+rows <- runQuery (userById uid) >>= either (const (throwError err503)) pure+user <- case rows of+ [] -> throwError err404+ [u] -> pure u+ _ -> throwError err500+```++write++```haskell+user <- runQuery (userById uid) ? err503 ?! cardinalityErr err404 (const err500)+```++Here `runQuery` returns `Eff es (Either DbError [row])`. Each operator+peels one layer (the database failure, then "exactly one row") and says+what to throw if it fails, so the happy path stays one line. A longer+[servant example](#example-a-servant-backend) is below.++The purpose of `railroad` is to error handle tersely, keeping the+happy-path clean of control-flow and functor unpacking. It does this+with 10 combinators, that abstract over binary co-products (`Bool`,+`Maybe`, `Either`, `Validation`) and Traversables or Folds over them.++```haskell+import Railroad.MonadError+import Control.Monad.Except++example :: Either String Int+example = runExcept $ do+ -- False ? "error" would make the block short circuit to Left "error"+ + x <- pure (Just 2) ? "Value missing"+ + -- Right (Just 1) ~> Just 1 ~> 1+ y <- pure (Right $ Just 1) ? "Outer fail" ? "Inner fail"+ + -- [Just 4] ~> [4] ~> 4+ z <- pure [Just 4] ? "List failed" ?! const "Not a single element"+ + q <- pure Nothing ?~ 1 + + pure (x + y + z + q) -- ~> Right 8 +```++You just need to work in a monad which supports a `MonadError`-like+context. Either by being an instance of `MonadError` or in an Eff+stack with `Error`.++[](https://www.haskell.org/)+[](LICENSE)++---++## Install++### Nix++Here is an expression you can add to `ghcWithPackages`:+```nix+railroad = pkgs.haskellPackages.callCabal2nix "railroad" (pkgs.fetchFromGitHub + { owner = "mastratisi"+ ; repo = "railroad"+ ; rev = "master"+ ; sha256 = pkgs.lib.fakeSha256; # Nix will tell you the real hash on first build + }) {};+```++### Hackage+The package is under the name `railroad`. Link: https://hackage.haskell.org/package/railroad++++## 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])`: a database failure+wrapping the rows. With `railroad`:++```haskell+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+ _ <- runQuery (permitted uid asset) ? err500 ? err403 -- db error, then "not permitted"+ ...+```++Each operator peels one layer, left to right: `? err503` unwraps the+`Either`, then `?!` demands exactly one row (none is a 404, several a+500). The same handlers in plain Haskell:++```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 failure paths no longer take up lines, and the order of the layers is+the order you read them in. Validating a request with a `Validation`+reports every problem at once: `pure (map validateFile files) ?? badRequest`+turns all the invalid files into one 400. `Railroad.MonadError` has the+same operators for plain `MonadError`, including servant's `Handler`.++## Understanding the Operators++| Operator | Purpose | Example |+|----------|-----------------------------------------------|-------------------------------|+| `??` | Derail on error with custom error mapping | `action ?? toMyError` |+| `?` | Derail with constant error | `action ? MyError` |+| `?>` | Derail on predicate | `(action ?> isGood) toErr` |+| `??~` | Recover with a mapped default (error → value) | `action ??~ toDefaultVal` |+| `?~` | Recover with a const default value | `action ?~ defaultVal` |+| `?+` | Derail on empty collection | `items ?+ NoResults` |+| `?!` | Derail if not exactly one element | `items ?! fromCardinalityErr` |+| `?∅` | Derail on non-empty collections (alias `?@`) | `items ?∅ DuplicateFound` |+| `?\|` | Fall back to another action on error | `cache k ?\| db k ? NotFound` |+| `?\|<>` | Fall back, combining the errors with `<>` | `a ?\|<> b ?? toErr` |+++For the semantics of the operators there are 3 relevant questions:+What counts as an error? What happens in the error case? What+happens in the success case?+++### What counts as an error?+Here we can split the operators into two families. Those that+interpret errors on the structure of Functors and Traversable of+Functor. Those that interpret error on the cardinality of Foldables.++For the former, they all (`?`, `??`, `?~`, `??~`) interpret what+an error is the same way:++ | Functor | Error Constructor | Error Info (`CErr`) | Result Info (`CRes`) |+ |------------------|----------------------|---------------------|----------------------|+ | `Bool` | `False` | `()` | `()` |+ | `Maybe a` | `Nothing` | `()` | `a` |+ | `Either e a` | `Left` | `e` | `a` |+ | `Validation e a` | `Failure` | `e` | `a` |+ | `t f` | Any element is error | `CErr f` | `t (CRes f)` |+++For the latter each operator (`?+`, `?!`, `?∅`) interpret what+cardinalities of a Foldable counts as errors differently. To wit:++| Operator | Success Condition | Error Info | Result Info |+|:------------|:--------------------|:-------------------------|:------------|+| `?+` | Non-empty | `()` | `t a` |+| `?!` | Exactly one element | `CardinalityError (t a)` | `a` |+| `?∅` / `?@` | Is empty | `t a` | `()` |++Where+```haskell+-- | Structurally a @Maybe ta@ for cardinality failures+data CardinalityError ta = IsEmpty | TooMany ta++-- | Catamorphism for CardinalityError+cardinalityErr :: e -> (ta -> e) -> CardinalityError ta -> e+```++### What happens in the error case?+When an error is detected, the railroad "derails":++1. The provided error-mapping function is applied to the **Error+ Info** (`CErr` or `CardinalityError`).+2. The result of the error mapping is thrown+3. This causes early termination of the monadic sequence.++**Recovery Operators (`?~`, `??~`):** Unlike the other operators,+these do **not** derail the computation. Instead, they provide a+"switch" to bring the logic back onto the happy path by providing a+default value through a recovery function.+++**Traversal case:** For `Bool`, `Maybe`, `Either` the error+accumulation is short-circuiting and stops at the first error. For +`t (Validation e a)` the semigroup constraint on `e` is used to+accumulate all errors together. For more information on accumulating+errors, see the+[validation](https://hackage.haskell.org/package/validation/docs/Data-Validation.html)+package on Hackage.+### What happens in the success case?+In the success case the result is unwrapped or validated (`?>`, `?+`)+and the monad continues its execution. Result Info in the above tables+shows the resulting type inside the monad.++### Fallback operators+`?|` and `?|<>` try one source after another and stop at the first+success. They are for sources that may be different structures (for+example a cache returning `Maybe` and a database returning `Either`)+with the same success type (`CRes`). The result is bifurcatable, so a+final `?` or `??` turns a total failure into a throw:++```haskell+cache k ?| db k ?| legacy k ? NotFound+```++`?|` keeps the last error. `?|<>` also needs the sources to share the+error type (`CErr`) and combines all the errors with `<>`, in source+order, when every source fails. Map differing error types to a common one+first, for example with `note`-like functions:++```haskell+(cache k <&> note [CacheMiss]) ?|<> (db k <&> first pure) ?? NotFound+```++Neither throws by itself, and the right-hand side only runs if the left+one failed. Unlike `Validation`, which runs all independent checks and+collects every failure, a fallback chain succeeds on the first good answer.++## More+See [`railroad.md`](railroad.md) for a deeper tour, or just read the+code at [`Railroad.hs`](src/Railroad.hs). It's a short file.++## Contact+Feel free to DM me on https://x.com/mastratisi97 . I find it+interesting to know if other people also have found this module+useful.
src/Railroad.hs view
@@ -4,6 +4,10 @@ {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE UndecidableInstances #-} +-- |+-- Railway-oriented operators: turn a failure in a 'Bifurcate' structure (@Bool@, @Maybe@,+-- @Either@, @Validation@, traversables of them) or a bad cardinality into a throw in the+-- @Error@ effect, keeping the happy path clean. For @MonadError@ use "Railroad.MonadError". module Railroad where import Data.Bool (bool)@@ -14,12 +18,16 @@ import Effectful.Error.Dynamic (Error, throwError_) +-- | What the failure case carries: @()@ for 'Bool' and 'Maybe', @e@ for 'Either' and+-- 'Validation', that of the element for a traversable. type family CErr f :: Type where CErr Bool = () CErr (Maybe a) = () CErr (Either e a) = e CErr (Validation e a) = e CErr (t a) = CErr a+-- | What the success case carries: @()@ for 'Bool', @a@ for 'Maybe', 'Either' and+-- 'Validation', @t (CRes a)@ for a traversable @t a@. type family CRes f :: Type where CRes Bool = () CRes (Maybe a) = a@@ -60,45 +68,74 @@ => Bifurcate (t (Validation e a)) where bifurcate = bifurcate . sequenceA --- | Collapses a structure into its inner type or an effectful error+-- | Unwraps the success case, or throws the failure mapped by the function. collapse :: (Error e :> es, Bifurcate f) => (CErr f -> e) -> f -> Eff es (CRes f) collapse toErr = either (throwError_ . toErr) pure . bifurcate --- | Collapses a structure using an error mapper.+-- | Unwraps the success case, or throws the error info mapped by the function. (??) :: forall a es e. (Error e :> es, Bifurcate a) => Eff es a -> (CErr a -> e) -> Eff es (CRes a) action ?? toErr = action >>= collapse toErr --- | Collapses a structure using a constant error.+-- | Unwraps the success case, or throws a constant error. Chains to peel nested layers:+-- @m ? e1 ? e2@. (?) :: forall es e a. (Error e :> es, Bifurcate a) => Eff es a -> e -> Eff es (CRes a) action ? err = action ?? const err --- | Collapses any value based on a predicate+-- | Passes the value on if it satisfies the predicate, else throws the error built from+-- it: @(m ?> p) toErr@. (?>) :: forall es e a. (Error e :> es) => Eff es a -> (a -> Bool) -> (a -> e) -> Eff es a (?>) action predicate toErr = do val <- action if predicate val then pure val else throwError_ $ toErr val --- | Collapses a structure and recovers to a value dependent on the error+-- | Unwraps the success case, or recovers with a value computed from the error info. (??~) :: forall es a. Bifurcate a => Eff es a -> (CErr a -> CRes a) -> Eff es (CRes a) action ??~ defaultFunc = action >>= either (pure . defaultFunc) pure . bifurcate --- | Collapses a structure, and recovers to a constant default value in the error case+-- | Unwraps the success case, or recovers with a default value. (?~) :: forall es a. Bifurcate a => Eff es a -> CRes a -> Eff es (CRes a) action ?~ defaultVal = action ??~ (const defaultVal) --- | @IsEmpty@ or @TooMany ta@ — structurally a @Maybe ta@ for cardinality failures+-- | Fallback: runs the right action only if the left fails. The sides may be different+-- structures with the same success type; the last error is kept. Finish with '?' or '??'.+--+-- > cache k ?| db k ?| legacy k ? NotFound+(?|) :: forall m a b. (Monad m, Bifurcate a, Bifurcate b, CRes a ~ CRes b)+ => m a -> m b -> m (Either (CErr b) (CRes a))+ma ?| mb = do+ a <- ma+ case bifurcate a of+ Right x -> pure (Right x)+ Left _ -> bifurcate <$> mb++-- | Like '?|', but if every source fails their errors are combined with '<>', in order.+-- The sides must also share the error type; map it to a common one first.+(?|<>) :: forall m a b. ( Monad m, Bifurcate a, Bifurcate b, CRes a ~ CRes b+ , CErr a ~ CErr b, Semigroup (CErr a) )+ => m a -> m b -> m (Either (CErr a) (CRes a))+ma ?|<> mb = do+ a <- ma+ case bifurcate a of+ Right x -> pure (Right x)+ Left ea -> do+ b <- mb+ pure $ case bifurcate b of+ Right y -> Right y+ Left eb -> Left (ea <> eb)++-- | Why a collection was not a single element: empty, or too many (carrying it). data CardinalityError ta = IsEmpty | TooMany ta --- | Catamorphism for CardinalityError+-- | Fold for 'CardinalityError': one result per case. cardinalityErr :: e -> (ta -> e) -> CardinalityError ta -> e cardinalityErr onEmpty onTooMany = \case IsEmpty -> onEmpty TooMany xs -> onTooMany xs --- | Non-empty is success state. Returns the collection as-is.+-- | Succeeds if non-empty, returning the collection; else throws the constant error. (?+) :: forall es e t a. (Error e :> es, Foldable t) => Eff es (t a) -> e -> Eff es (t a) (?+) action err = do@@ -106,7 +143,8 @@ if null xs then throwError_ err else pure xs --- | Single element is success state. Returns the single element+-- | Succeeds if there is exactly one element, returning it; else throws the error built+-- from the 'CardinalityError'. (?!) :: forall es e t a. (Error e :> es, Foldable t) => Eff es (t a) -> (CardinalityError (t a) -> e) -> Eff es a (?!) action toErr = do@@ -117,17 +155,17 @@ _ -> throwError_ $ toErr $ TooMany xs --- | Empty is success state. Returns Unit.+-- | Succeeds if empty, returning @()@; else throws the error built from the collection. (?∅) :: forall es e t a. (Error e :> es, Foldable t) => Eff es (t a) -> ((t a) -> e) -> Eff es () (?∅) action toErr = do xs <- action if null xs then pure () else throwError_ (toErr xs) --- | Non-unicode alias for `(?∅)`+-- | ASCII alias for '?∅'. (?@) :: forall es e t a. (Error e :> es, Foldable t) => Eff es (t a) -> ((t a) -> e) -> Eff es () (?@) = (?∅) -infixl 0 ??, ?, ??~, ?~, ?!, ?+, ?∅, ?@+infixl 0 ??, ?, ??~, ?~, ?!, ?+, ?∅, ?@, ?|, ?|<> infixl 1 ?>
src/Railroad/MonadError.hs view
@@ -1,9 +1,6 @@ -- |--- Re-implementation of Railroad operators ('??', '?', '?>', etc.)--- using 'MonadError' / 'throwError' instead of the Effectful 'Error' effect.------ Use this module in classic @mtl@ / @transformers@ / @ExceptT@ code.--- For Effectful prefer the versions from "Railroad".+-- The operators of "Railroad" for any 'MonadError' (@mtl@, @transformers@, @ExceptT@,+-- servant's @Handler@) instead of the @Error@ effect. In an @effectful@ stack use "Railroad". {-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE FlexibleContexts #-} @@ -19,44 +16,47 @@ import Control.Monad.Except (MonadError (..)) import Data.Foldable (toList) --- | Collapses a structure into its inner type or a `MonadError` error.+-- | Unwraps the success case, or throws the failure mapped by the function. collapse :: (MonadError e m, Bifurcate f) => (CErr f -> e) -> f -> m (CRes f) collapse toErr = either (throwError . toErr) pure . bifurcate --- | Collapses a structure using an error mapper.+-- | Unwraps the success case, or throws the error info mapped by the function. (??) :: forall a m e. (MonadError e m, Bifurcate a) => m a -> (CErr a -> e) -> m (CRes a) action ?? toErr = action >>= collapse toErr --- | Collapses a structure using a constant error.+-- | Unwraps the success case, or throws a constant error. Chains to peel nested layers:+-- @m ? e1 ? e2@. (?) :: forall m e a. (MonadError e m, Bifurcate a) => m a -> e -> m (CRes a) action ? err = action ?? const err --- | Collapses any value based on a predicate+-- | Passes the value on if it satisfies the predicate, else throws the error built from+-- it: @(m ?> p) toErr@. (?>) :: forall m e a. (MonadError e m) => m a -> (a -> Bool) -> (a -> e) -> m a (?>) action predicate toErr = do val <- action if predicate val then pure val else throwError $ toErr val --- | Collapses a structure and recovers to a value dependent on the error+-- | Unwraps the success case, or recovers with a value computed from the error info. (??~) :: forall m a. (Bifurcate a, Monad m) => m a -> (CErr a -> CRes a) -> m (CRes a) action ??~ defaultFunc = action >>= either (pure . defaultFunc) pure . bifurcate --- | Collapses a structure, and recovers to a constant default value in the error case+-- | Unwraps the success case, or recovers with a default value. (?~) :: forall m a. (Bifurcate a, Monad m) => m a -> CRes a -> m (CRes a) action ?~ defaultVal = action ??~ (const defaultVal) --- | Non-empty is success state. Returns the collection as-is.+-- | Succeeds if non-empty, returning the collection; else throws the constant error. (?+) :: forall m e t a. (MonadError e m, Foldable t) => m (t a) -> e -> m (t a) (?+) action err = do xs <- action if null xs then throwError err else pure xs --- | Single element is success state. Returns the single element+-- | Succeeds if there is exactly one element, returning it; else throws the error built+-- from the 'CardinalityError'. (?!) :: forall m e t a. (MonadError e m, Foldable t) => m (t a) -> (CardinalityError (t a) -> e) -> m a (?!) action toErr = do@@ -66,14 +66,14 @@ [x] -> pure x _ -> throwError $ toErr $ TooMany xs --- | Empty is success state. Returns Unit.+-- | Succeeds if empty, returning @()@; else throws the error built from the collection. (?∅) :: forall m e t a. (MonadError e m, Foldable t) => m (t a) -> (t a -> e) -> m () (?∅) action toErr = do xs <- action if null xs then pure () else throwError (toErr xs) --- | Non-unicode alias for `(?∅)`+-- | ASCII alias for '?∅'. (?@) :: forall m e t a. (MonadError e m, Foldable t) => m (t a) -> (t a -> e) -> m () (?@) = (?∅)
test/Railroad/MonadErrorSpec.hs view
@@ -4,6 +4,8 @@ module Railroad.MonadErrorSpec where import Control.Monad.Except+import Control.Monad.State (State, modify, runState)+import Data.Validation (Validation (..)) import Data.Functor.Identity import Railroad.MonadError import Test.Hspec@@ -12,6 +14,13 @@ runMonadError :: ExceptT String Identity a -> Either String a runMonadError = runIdentity . runExceptT +-- Like 'runMonadError', and counts how many 'tick's ran.+runCount :: ExceptT String (State Int) a -> (Either String a, Int)+runCount m = runState (runExceptT m) 0++tick :: ExceptT String (State Int) ()+tick = modify (+ 1)+ spec :: Spec spec = do describe "MonadError version of Operators" $ do@@ -57,3 +66,39 @@ runMonadError (pure [] ?∅ const "not empty") `shouldBe` Right () it "fails on non-empty" $ do runMonadError (pure [1 :: Int] ?∅ const "not empty") `shouldBe` Left "not empty"++ describe "Fallback Operator (?|)" $ do+ it "does not run the right action when the left succeeds" $ do+ runCount (pure (Just 'a') ?| (tick >> pure (Nothing :: Maybe Char)) ? "none")+ `shouldBe` (Right 'a', 0)+ it "runs the right action when the left fails" $ do+ runCount (pure (Nothing :: Maybe Int) ?| (tick >> pure (Just 7)) ? "none")+ `shouldBe` (Right 7, 1)+ it "keeps the last error when everything fails" $ do+ runCount (pure (Left "first" :: Either String Int) ?| pure (Left "second") ?? id)+ `shouldBe` (Left "second", 0)+ runCount (pure (Nothing :: Maybe Int) ?| pure Nothing ? "none")+ `shouldBe` (Left "none", 0)+ it "chains mixed structures, stopping at the first success" $ do+ let chain = pure (Nothing :: Maybe Int) ?| pure (Left "db" :: Either String Int)+ ?| (tick >> pure (Just 3)) ?| (tick >> tick >> pure (Just 4)) ? "gone"+ runCount chain `shouldBe` (Right 3, 1)++ describe "Accumulating Fallback Operator (?|<>)" $ do+ it "does not run the right action when the left succeeds" $ do+ runCount (pure (Just 'a') ?|<> (tick >> pure (Nothing :: Maybe Char)) ? "none")+ `shouldBe` (Right 'a', 0)+ it "combines the errors in source order when everything fails" $ do+ runCount (pure (Left "A" :: Either String Int) ?|<> pure (Left "B") ?|<> pure (Left "C") ?? id)+ `shouldBe` (Left "ABC", 0)+ it "mixes structures with the same error and success types" $ do+ runCount (pure (Left "A" :: Either String Int) ?|<> pure (Failure "B" :: Validation String Int) ?? id)+ `shouldBe` (Left "AB", 0)+ runCount (pure (Left "A" :: Either String Int) ?|<> (tick >> pure (Success 5 :: Validation String Int)) ?? id)+ `shouldBe` (Right 5, 1)+ it "drops earlier errors once a later source succeeds" $ do+ runCount (pure (Left "A" :: Either String Int) ?|<> pure (Left "B") ?|<> pure (Right 3) ?? id)+ `shouldBe` (Right 3, 0)+ it "needs nothing special for Maybe sources" $ do+ runCount (pure (Nothing :: Maybe Int) ?|<> pure Nothing ? "none")+ `shouldBe` (Left "none", 0)
test/RailroadSpec.hs view
@@ -6,6 +6,7 @@ import Data.Validation import Effectful import Effectful.Error.Dynamic+import Effectful.State.Static.Local import Railroad import Test.Hspec @@ -13,6 +14,13 @@ runRail :: Eff '[Error String] a -> Either String a runRail = runPureEff . runErrorNoCallStack +-- Like 'runRail', and counts how many 'tick's ran.+runCount :: Eff '[Error String, State Int] a -> (Either String a, Int)+runCount = runPureEff . runState 0 . runErrorNoCallStack++tick :: State Int :> es => Eff es ()+tick = modify (+ (1 :: Int))+ spec :: Spec spec = do describe "Bifurcate Instances" $ do@@ -99,3 +107,46 @@ runRail (pure [] ?∅ const "not empty") `shouldBe` Right () it "fails on non-empty" $ do runRail (pure [1 :: Int] ?∅ const "not empty") `shouldBe` Left "not empty"++ describe "Fallback Operator (?|)" $ do+ it "does not run the right action when the left succeeds" $ do+ runCount (pure (Just 'a') ?| (tick >> pure (Nothing :: Maybe Char)) ? "none")+ `shouldBe` (Right 'a', 0)++ it "runs the right action when the left fails" $ do+ runCount (pure (Nothing :: Maybe Int) ?| (tick >> pure (Just 7)) ? "none")+ `shouldBe` (Right 7, 1)++ it "keeps the last error when everything fails" $ do+ runCount (pure (Left "first" :: Either String Int) ?| pure (Left "second") ?? id)+ `shouldBe` (Left "second", 0)+ runCount (pure (Nothing :: Maybe Int) ?| pure Nothing ? "none")+ `shouldBe` (Left "none", 0)++ it "chains mixed structures, stopping at the first success" $ do+ let chain = pure (Nothing :: Maybe Int) ?| pure (Left "db" :: Either String Int)+ ?| (tick >> pure (Just 3)) ?| (tick >> tick >> pure (Just 4)) ? "gone"+ runCount chain `shouldBe` (Right 3, 1)++ describe "Accumulating Fallback Operator (?|<>)" $ do+ it "does not run the right action when the left succeeds" $ do+ runCount (pure (Just 'a') ?|<> (tick >> pure (Nothing :: Maybe Char)) ? "none")+ `shouldBe` (Right 'a', 0)++ it "combines the errors in source order when everything fails" $ do+ runCount (pure (Left "A" :: Either String Int) ?|<> pure (Left "B") ?|<> pure (Left "C") ?? id)+ `shouldBe` (Left "ABC", 0)++ it "mixes structures with the same error and success types" $ do+ runCount (pure (Left "A" :: Either String Int) ?|<> pure (Failure "B" :: Validation String Int) ?? id)+ `shouldBe` (Left "AB", 0)+ runCount (pure (Left "A" :: Either String Int) ?|<> (tick >> pure (Success 5 :: Validation String Int)) ?? id)+ `shouldBe` (Right 5, 1)++ it "drops earlier errors once a later source succeeds" $ do+ runCount (pure (Left "A" :: Either String Int) ?|<> pure (Left "B") ?|<> pure (Right 3) ?? id)+ `shouldBe` (Right 3, 0)++ it "needs nothing special for Maybe sources" $ do+ runCount (pure (Nothing :: Maybe Int) ?|<> pure Nothing ? "none")+ `shouldBe` (Left "none", 0)