railroad 0.2.0.1 → 0.2.1.0
raw patch · 9 files changed
+101/−15 lines, 9 filesPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
API changes (from Hackage documentation)
+ Railroad.Bifurcate: (?<*) :: (Monad m, Bifurcate a) => m a -> (CErr a -> m ()) -> m a
- Railroad.Bifurcate: infixl 1 ?|<>
+ Railroad.Bifurcate: infixl 1 ?<*
Files
- CHANGELOG.md +8/−0
- railroad.cabal +46/−4
- readme.md +20/−5
- src/Railroad.hs +1/−1
- src/Railroad/Bifurcate.hs +8/−3
- src/Railroad/MonadError.hs +1/−1
- test/Railroad/BifurcateSpec.hs +6/−1
- test/Railroad/MonadErrorSpec.hs +5/−0
- test/RailroadSpec.hs +6/−0
CHANGELOG.md view
@@ -1,5 +1,13 @@ # Revision history for railroad +## 0.2.1.0 -- 2026-10-11+* Add the tap operator `(?<*)`: runs an effect on the failure and passes the original value through+ unchanged, so a failure can be logged or counted before a constant error throws it away:+ `runQuery q ?<* logDbError ? err503`. The handler is a function of the failure (`CErr a -> m ()`)+ and runs only when there is one. It needs only `Monad`, never throws, and is defined once, in+ `Railroad.Bifurcate`, so `Railroad` and `Railroad.MonadError` re-export it.+* Documentation: say "unwrap" instead of "peel" for taking apart one layer of a result.+ ## 0.2.0.1 -- 2026-10-11 * Documentation: a README section on the empty-collection gotcha (`?` on a traversable checks that every element succeeds, so an empty one passes; chain `?+` before it), with the quantifiers each
railroad.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: railroad-version: 0.2.0.1+version: 0.2.1.0 license: BSD-3-Clause license-file: LICENSE author: Frederik Kallstrup Mastratisi@@ -9,10 +9,52 @@ build-type: Simple 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.+synopsis: Railway-oriented error handling: unwrap or throw in one short operator. description:- A railway oriented mini-DSL for handling and interpreting error states - and cardinality checks across diverse failure types in a unified way.+ Railway-oriented error handling for Haskell: one short operator per+ step, and the failure paths stop taking up lines.++ Each operator unwraps one layer of a result (@Maybe@, @Either@, @Bool@,+ @Validation@, or a collection of them) and says what to throw if it+ failed. 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).++ * Works in any @MonadError@ ("Railroad.MonadError": @mtl@, @ExceptT@,+ servant's @Handler@) and in @effectful@ with the @Error@ effect+ ("Railroad").++ * Unwrap or throw: @?@ (constant error) and @??@ (error computed from+ the failure). Recover instead of throwing: @?~@ and @??~@.++ * Tag by a predicate with @?>@, or fall back to another source with @?|@+ and @?|<>@.++ * Log a failure before a constant error discards it, with @?\<*@:+ @runQuery q ?\<* logDbError ? err503@.++ * @Validation@ failures accumulate: validate a whole request and report+ every problem at once.++ * All operators are @infixl 1@ like @>>=@ and @\<&\>@, so they chain without+ parentheses.++ Collections are checked with quantifiers: @?@ is ∀ ("every element+ succeeds", so an empty collection passes), @?+@ is ∃ ("at least one"),+ @?!@ is ∃! ("exactly one") and @?∅@ is ¬∃ ("none"). They chain:+ @xs ?+ e1 ? e2@ means "at least one, and all succeed". The readme has the+ details and a longer servant example. homepage: https://github.com/mastratisi/railroad bug-reports: https://github.com/mastratisi/railroad/issues source-repository head
readme.md view
@@ -1,6 +1,6 @@ # railroad -**A terse Haskell railroad error handling DSL abstracting over error functors by catamorphism via `Either`.**+**A terse railway-oriented error handling DSL for Haskell. Unwrap `Maybe`, `Either`, `Bool` or `Validation`, or throw a mapped error, with one short operator.** Instead of @@ -19,13 +19,13 @@ ``` Here `runQuery` returns `Eff es (Either DbError [row])`. Each operator-peels one layer (the database failure, then "exactly one row") and says+unwraps 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`,+with 11 combinators, that abstract over binary co-products (`Bool`, `Maybe`, `Either`, `Validation`) and Traversables or Folds over them. ```haskell@@ -63,7 +63,7 @@ - `Railroad`: the operators for the `effectful` `Error` effect. - `Railroad.MonadError`: the same operators for any `MonadError` (`mtl`, `ExceptT`, servant's `Handler`). - `Railroad.Bifurcate`: the `Bifurcate` class and the operators that never throw- (`?>`, `?~`, `??~`, `?|`, `?|<>`); `Railroad.Cardinality`: `CardinalityError`.+ (`?>`, `?~`, `??~`, `?|`, `?|<>`, `?<*`); `Railroad.Cardinality`: `CardinalityError`. Both of the above re-export these, so you rarely import them directly. ## Install@@ -105,11 +105,12 @@ updateAsset :: Error ServerError :> es => UserId -> Asset -> Eff es () updateAsset uid asset = do+ -- permitted is an SQL exists, so it always returns exactly one row (see the gotcha below) _ <- runQuery (permitted uid asset) ? err500 ? err403 -- db error, then "not permitted" ... ``` -Each operator peels one layer, left to right: `? err503` unwraps the+Each operator unwraps 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: @@ -153,6 +154,7 @@ | `?∅` | 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` |+| `?<*` | Run an effect on a failure, then carry on | `action ?<* logIt ? MyError` | All operators are `infixl 1`, like `>>=`, `<&>` and `&`, so they chain@@ -250,6 +252,19 @@ 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.++### Observing failures+A constant error such as `? 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:++```haskell+runQuery q ?<* logDbError ? err503+```++The handler is a function of the failure (`CErr`), returns `m ()`, and+runs only when there is a failure. Like `<*` it keeps the left value, and+like the other operators in `Railroad.Bifurcate` it never throws. ## Gotcha: an empty collection passes
src/Railroad.hs view
@@ -28,7 +28,7 @@ (??) :: forall a es e. (Error e :> es, Bifurcate a) => Eff es a -> (CErr a -> e) -> Eff es (CRes a) action ?? toErr = action >>= collapse toErr --- | Unwraps the success case, or throws a constant error. Chains to peel nested layers:+-- | Unwraps the success case, or throws a constant error. Chains to unwrap 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
src/Railroad/Bifurcate.hs view
@@ -10,7 +10,7 @@ -- throw. The throwing operators are in "Railroad" and "Railroad.MonadError". module Railroad.Bifurcate ( Bifurcate (..), CErr, CRes- , (?>), (??~), (?~), (?|), (?|<>)+ , (?>), (??~), (?~), (?|), (?|<>), (?<*) ) where import Data.Bifunctor (first)@@ -58,7 +58,7 @@ -- validation :: (e -> b) -> (a -> b) -> Validation e a -> b -- The traversable instances overlap the base ones for e.g. @Either e (Maybe a)@. They are--- INCOHERENT so the base instance wins and the outer layer is peeled first (@m ? e1 ? e2@).+-- INCOHERENT so the base instance wins and the outer layer is unwrapped first (@m ? e1 ? e2@). instance {-# INCOHERENT #-} (Traversable t, CErr (t Bool) ~ (), CRes (t Bool) ~ t ()) => Bifurcate (t Bool) where bifurcate = bifurcate . sequenceA . fmap bifurcate@@ -104,4 +104,9 @@ => m a -> m b -> m (Either (CErr a) (CRes a)) ma ?|<> mb = ma >>= either (\ea -> first (ea <>) . bifurcate <$> mb) (pure . Right) . bifurcate -infixl 1 ??~, ?~, ?|, ?|<>, ?>+-- | Runs an effect on the failure and passes the value through unchanged. Put it before @?@+-- or @??@ to log what they would discard: @m ?\<* logIt ? err@.+(?<*) :: forall m a. (Monad m, Bifurcate a) => m a -> (CErr a -> m ()) -> m a+action ?<* handler = action >>= \x -> x <$ either handler (const (pure ())) (bifurcate x)++infixl 1 ??~, ?~, ?|, ?|<>, ?>, ?<*
src/Railroad/MonadError.hs view
@@ -26,7 +26,7 @@ => m a -> (CErr a -> e) -> m (CRes a) action ?? toErr = action >>= collapse toErr --- | Unwraps the success case, or throws a constant error. Chains to peel nested layers:+-- | Unwraps the success case, or throws a constant error. Chains to unwrap nested layers: -- @m ? e1 ? e2@. (?) :: forall m e a. (MonadError e m, Bifurcate a) => m a -> e -> m (CRes a)
test/Railroad/BifurcateSpec.hs view
@@ -35,7 +35,7 @@ bifurcate xs === (case [e | Failure e <- xs] of [] -> Right [a | Success a <- xs] es -> Left (concat es))- it "peels only the outermost base layer" $ property $+ it "unwraps only the outermost base layer" $ property $ \(e :: Either Int (Maybe Int)) (m :: Maybe (Maybe Int)) (b :: Either Int Bool) -> (bifurcate e === e) .&&. (bifurcate m === maybe (Left ()) Right m) .&&. (bifurcate b === b) @@ -47,6 +47,11 @@ \(x :: a) (d :: CRes a) (Fn f :: Fun (CErr a) (CRes a)) -> (runIdentity (pure x ?~ d) === either (const d) id (bifurcate x)) .&&. (runIdentity (pure x ??~ f) === either f id (bifurcate x))++ forShapes "?<* runs the handler on a failure only and passes the value through" $ \(_ :: Proxy a) -> property $+ \(x :: a) ->+ let (y, logged) = runState (pure x ?<* (\e -> modify (e :))) ([] :: [CErr a])+ in (bifurcate y === bifurcate x) .&&. (logged === either pure (const []) (bifurcate x)) describe "?| (fallback, last error kept)" $ do let law :: forall a b. (Shape a, Shape b, CRes a ~ CRes b) => Proxy a -> Proxy b -> Property
test/Railroad/MonadErrorSpec.hs view
@@ -4,6 +4,7 @@ module Railroad.MonadErrorSpec where import Control.Monad.Except+import Control.Monad.State (modify, runState) import Data.Bifunctor (first) import Data.Functor ((<&>)) import Data.Functor.Identity@@ -54,6 +55,10 @@ it "chains the fallback with ? and ?>" $ do runMonadError (pure (Nothing :: Maybe Int) ?| pure (Right 9 :: Either String Int) ? "none" ?> (> 5) ? "small") `shouldBe` Right 9+ it "logs a failure with ?<*, then still throws the mapped error" $ do+ let run m = runState (runExceptT m) ([] :: [Int])+ run (pure (Left 3 :: Either Int Int) ?<* (\e -> modify (e :)) ? "failed") `shouldBe` (Left "failed", [3])+ run (pure (Right 4 :: Either Int Int) ?<* (\e -> modify (e :)) ? "failed") `shouldBe` (Right 4, []) it "gives the rejected value of ?> to the error mapper" $ do runMonadError (pure (4 :: Int) ?> (> 5) ?? \n -> "rejected " ++ show n) `shouldBe` Left "rejected 4"
test/RailroadSpec.hs view
@@ -7,6 +7,7 @@ import Data.Proxy (Proxy (..)) import Effectful import Effectful.Error.Dynamic+import Effectful.State.Static.Local (State, modify, runState) import Model import Railroad import Test.Hspec@@ -53,5 +54,10 @@ it "chains the fallback with ? and ?>" $ do runRail (pure (Nothing :: Maybe Int) ?| pure (Right 9 :: Either String Int) ? "none" ?> (> 5) ? "small") `shouldBe` Right 9+ it "logs a failure with ?<*, then still throws the mapped error" $ do+ let run :: Eff '[Error String, State [Int]] a -> (Either String a, [Int])+ run = runPureEff . runState [] . runErrorNoCallStack+ run (pure (Left 3 :: Either Int Int) ?<* (\e -> modify (e :)) ? "failed") `shouldBe` (Left "failed", [3])+ run (pure (Right 4 :: Either Int Int) ?<* (\e -> modify (e :)) ? "failed") `shouldBe` (Right 4, []) it "gives the rejected value of ?> to the error mapper" $ do runRail (pure (4 :: Int) ?> (> 5) ?? \n -> "rejected " ++ show n) `shouldBe` Left "rejected 4"