packages feed

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 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"