railroad 0.2.1.0 → 0.3.0.0
raw patch · 10 files changed
+547/−251 lines, 10 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
- Railroad.Cardinality: IsEmpty :: CardinalityError ta
- Railroad.Cardinality: TooMany :: ta -> CardinalityError ta
- Railroad.Cardinality: cardinalityErr :: e -> (ta -> e) -> CardinalityError ta -> e
- Railroad.Cardinality: data CardinalityError ta
+ Railroad: (?∃) :: forall (es :: [Effect]) e t a. (Error e :> es, Foldable t, Bifurcate a) => Eff es (t a) -> ([CErr a] -> e) -> Eff es [CRes a]
+ Railroad.Bifurcate: IsEmpty :: CardinalityError ta
+ Railroad.Bifurcate: TooMany :: ta -> CardinalityError ta
+ Railroad.Bifurcate: anySucceeds :: (Foldable t, Bifurcate a) => t a -> Either [CErr a] [CRes a]
+ Railroad.Bifurcate: atLeastOne :: Foldable t => t a -> Maybe (t a)
+ Railroad.Bifurcate: cardinalityErr :: e -> (ta -> e) -> CardinalityError ta -> e
+ Railroad.Bifurcate: data CardinalityError ta
+ Railroad.Bifurcate: exactlyOne :: Foldable t => t a -> Either (CardinalityError (t a)) a
+ Railroad.Bifurcate: failures :: (Foldable t, Bifurcate a) => t a -> [CErr a]
+ Railroad.Bifurcate: instance GHC.Classes.Eq ta => GHC.Classes.Eq (Railroad.Bifurcate.CardinalityError ta)
+ Railroad.Bifurcate: instance GHC.Internal.Show.Show ta => GHC.Internal.Show.Show (Railroad.Bifurcate.CardinalityError ta)
+ Railroad.Bifurcate: neg :: Bifurcate a => a -> Either (CRes a) (CErr a)
+ Railroad.Bifurcate: noneAtAll :: Foldable t => t a -> Either (t a) ()
+ Railroad.Bifurcate: partitionBifurcate :: (Foldable t, Bifurcate a) => t a -> ([CErr a], [CRes a])
+ Railroad.Bifurcate: successes :: (Foldable t, Bifurcate a) => t a -> [CRes a]
+ Railroad.Bifurcate: tagBy :: (a -> Bool) -> a -> Either a a
+ Railroad.MonadError: (?∃) :: forall m e t a. (MonadError e m, Foldable t, Bifurcate a) => m (t a) -> ([CErr a] -> e) -> m [CRes a]
- Railroad: infixl 1 ?@
+ Railroad: infixl 1 ?∃
- Railroad.MonadError: infixl 1 ?@
+ Railroad.MonadError: infixl 1 ?∃
Files
- CHANGELOG.md +16/−0
- railroad.cabal +29/−25
- readme.md +258/−168
- src/Railroad.hs +18/−18
- src/Railroad/Bifurcate.hs +86/−4
- src/Railroad/Cardinality.hs +0/−17
- src/Railroad/MonadError.hs +18/−18
- test/Railroad/BifurcateSpec.hs +90/−1
- test/Railroad/MonadErrorSpec.hs +16/−0
- test/RailroadSpec.hs +16/−0
CHANGELOG.md view
@@ -1,5 +1,21 @@ # Revision history for railroad +## 0.3.0.0 -- unreleased+* The test suite and CI now run on GHC 9.10, 9.12 and 9.14 (`tested-with`). The dependency bounds are+ unchanged, so older GHCs are still allowed but are no longer tested.+* Breaking: `Railroad.Cardinality` is merged into `Railroad.Bifurcate`. `CardinalityError` and+ `cardinalityErr` are exported from `Railroad.Bifurcate`, and still from `Railroad` and+ `Railroad.MonadError`.+* New classifiers, each a plain function into `Either`: `neg` (swap failure and success),+ `tagBy` (the classifier behind `?>`), `atLeastOne`, `exactlyOne` and `noneAtAll` (behind `?+`,+ `?!` and `?∅`), `partitionBifurcate`, `successes`, `failures` and `anySucceeds`. Any verb applies to a classified value:+ `xs <&> exactlyOne ??~ f`, `xs <&> anySucceeds ?<* h ? e`.+* New operator `(?∃)`: succeeds if at least one element succeeds, returning the successes, and+ otherwise throws an error built from every failure. `xs ?∃ f` is `xs <&> anySucceeds ?? f`.+* `(?>)`, `(?+)`, `(?!)` and `(?∅)` are now defined over the classifiers. Their types and behaviour+ are unchanged.+* `CardinalityError` derives `Eq` and `Show`.+ ## 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:
railroad.cabal view
@@ -1,22 +1,22 @@ cabal-version: 3.0 name: railroad-version: 0.2.1.0+version: 0.3.0.0 license: BSD-3-Clause license-file: LICENSE author: Frederik Kallstrup Mastratisi maintainer: mastratisi@proton.me category: Control, Error Handling 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+tested-with: GHC == 9.10.3, GHC == 9.12.4, GHC == 9.14.1 extra-doc-files: CHANGELOG.md, readme.md-synopsis: Railway-oriented error handling: unwrap or throw in one short operator.+synopsis: Railway-oriented error handling: unwrap, throw, recover or fall back description: 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+ 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@@ -31,30 +31,35 @@ 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").+ What to do about a failure: - * Unwrap or throw: @?@ (constant error) and @??@ (error computed from- the failure). Recover instead of throwing: @?~@ and @??~@.+ * Throw: @?@ (constant error) and @??@ (error computed from the failure). - * Tag by a predicate with @?>@, or fall back to another source with @?|@- and @?|<>@.+ * Recover: @?~@ and @??~@. - * Log a failure before a constant error discards it, with @?\<*@:- @runQuery q ?\<* logDbError ? err503@.+ * Observe, then carry on: @?\<*@, as in @runQuery q ?\<* logDbError ? err503@. - * @Validation@ failures accumulate: validate a whole request and report- every problem at once.+ * Fall back to another source: @?|@, or @?|<>@ to combine the errors. - * All operators are @infixl 1@ like @>>=@ and @\<&\>@, so they chain without- parentheses.+ What counts as a failure: - 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.+ * @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. homepage: https://github.com/mastratisi/railroad bug-reports: https://github.com/mastratisi/railroad/issues source-repository head@@ -73,7 +78,6 @@ import: stuff exposed-modules: Railroad Railroad.Bifurcate- Railroad.Cardinality Railroad.MonadError hs-source-dirs: src default-language: GHC2021
readme.md view
@@ -1,7 +1,10 @@ # railroad -**A terse railway-oriented error handling DSL for Haskell. Unwrap `Maybe`, `Either`, `Bool` or `Validation`, or throw a mapped error, with one short operator.**+**Railway-oriented error handling for Haskell. Unwrap a `Maybe`, `Either`, `Bool` or `Validation`, or throw a mapped error, with one short operator.** +[](https://www.haskell.org/)+[](LICENSE)+ Instead of ```haskell@@ -18,15 +21,12 @@ user <- runQuery (userById uid) ? err503 ?! cardinalityErr err404 (const err500) ``` -Here `runQuery` returns `Eff es (Either DbError [row])`. Each operator-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.+`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). -The purpose of `railroad` is to error handle tersely, keeping the-happy-path clean of control-flow and functor unpacking. It does this-with 11 combinators, that abstract over binary co-products (`Bool`,-`Maybe`, `Either`, `Validation`) and Traversables or Folds over them.+It works in any `MonadError` (`mtl`, `ExceptT`, servant's `Handler`) and in+`effectful` with the `Error` effect. ```haskell import Railroad.MonadError@@ -34,63 +34,76 @@ 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 + 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 ``` -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`.+## How it works -[](https://www.haskell.org/)-[](LICENSE)+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+``` -## Modules+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: -- `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`.- Both of the above re-export these, so you rarely import them directly.+| 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`](railroad.md).++## Contents++- [How it works](#how-it-works)+- [Install](#install)+- [Example: a servant backend](#example-a-servant-backend)+- [The operators](#the-operators)+- [What counts as an error](#what-counts-as-an-error)+- [Gotcha: an empty collection passes](#gotcha-an-empty-collection-passes)+- [Behavior under an evolving code base](#behavior-under-an-evolving-code-base)+- [Exercises](#exercises)+- [Modules](#modules)+ ## Install +### Hackage++The package is called `railroad`: https://hackage.haskell.org/package/railroad+ ### Nix -Here is an expression you can add to `ghcWithPackages`:+An expression for `ghcWithPackages`:+ ```nix-railroad = pkgs.haskellPackages.callCabal2nix "railroad" (pkgs.fetchFromGitHub +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 + ; sha256 = pkgs.lib.fakeSha256; # Nix prints the real hash on the 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`:+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])`. ```haskell getUser :: Error ServerError :> es => UserId -> Eff es UserDTO@@ -105,14 +118,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)+ -- `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" ... ``` -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:+The same handlers in plain Haskell: ```haskell getUser' uid = do@@ -134,172 +145,251 @@ ... ``` -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` |-| `?>` | Tag by predicate (`Left` carries the value) | `action ?> isGood ? NotGood` |-| `??~` | 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` |-| `?<*` | Run an effect on a failure, then carry on | `action ?<* logIt ? MyError` |---All operators are `infixl 1`, like `>>=`, `<&>` and `&`, so they chain-left to right with them without parentheses:-`runQuery q ? err503 <&> toDTO`.--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?-+## The operators -### 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.+| 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` | -For the former, they all (`?`, `??`, `?~`, `??~`) interpret what-an error is the same way:+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. - | 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)` |+### Classifiers -An empty `t f` has no element to fail, so it succeeds: see [the gotcha](#gotcha-an-empty-collection-passes).+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: -For the latter each operator (`?+`, `?!`, `?∅`) interpret what-cardinalities of a Foldable counts as errors differently. To wit:+```haskell+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+``` -| Operator | Success Condition | Error Info | Result Info |-|:------------|:--------------------|:-------------------------|:------------|-| `?+` | Non-empty | `()` | `t a` |-| `?!` | Exactly one element | `CardinalityError (t a)` | `a` |-| `?∅` / `?@` | Is empty | `t a` | `()` |+The classifiers in the library are pure functions in `Railroad.Bifurcate` and need no+monad: -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+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] ``` -### What happens in the error case?-When an error is detected, the railroad "derails":+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): -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.+| 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` | -**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.+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. -**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.+### Fallbacks -### 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:+`?|` 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: ```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:+`?|<>` 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: ```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.+`Validation` 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: +`? 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.+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` | `()` |++```haskell+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 []`:+On a traversable, `?` and `??` check that **every** element succeeds (∀). Over+an empty collection that holds vacuously, like `and []`: ```haskell pure ([] :: [Bool]) ? err403 -- succeeds, with [] ``` -That surprises a permission check where "no rows" should mean "not-permitted". Require at least one element first, with `?+` (∃), and-chain the two checks:+In a permission check, "no rows" should mean "not permitted", so require at least+one element first, with `?+`: ```haskell _ <- runQuery (permitted uid asset) ? err500 ?+ err403 ? err403--- ^ db error ^ >= 1 row ^ all True+-- ^ db error ^ >= 1 row ^ all True ``` -The cardinality operators are the other quantifiers, so the usual-conditions are each one operator, and they chain:+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 |-| `?+` then `?` | there is one, and every one succeeds | (∃ x ∈ xs) ∧ (∀ x ∈ xs. succeeds x) |+| 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) | -## 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.+## 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`](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:++```haskell+-- 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`](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`](railroad.md).+ ## 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++DM me on https://x.com/mastratisi97 . I like to hear if other people find this useful.
src/Railroad.hs view
@@ -7,16 +7,14 @@ -- @Error@ effect, keeping the happy path clean. For @MonadError@ use "Railroad.MonadError". module Railroad ( module Railroad.Bifurcate- , module Railroad.Cardinality , collapse- , (??), (?), (?+), (?!), (?∅), (?@)+ , (??), (?), (?+), (?!), (?∅), (?@), (?∃) ) where -import Data.Foldable (toList)+import Data.Functor ((<&>)) import Effectful (Eff, type (:>)) import Effectful.Error.Dynamic (Error, throwError_) import Railroad.Bifurcate-import Railroad.Cardinality -- | Unwraps the success case, or throws the failure mapped by the function.@@ -36,35 +34,37 @@ -- | Succeeds if non-empty, returning the collection; else throws the constant error. -- Chain it before @?@ on a traversable, which an empty collection passes.+-- Sugar for @xs <&> 'atLeastOne' ? err@. (?+) :: forall es e t a. (Error e :> es, Foldable t) => Eff es (t a) -> e -> Eff es (t a)-(?+) action err = do- xs <- action- if null xs then throwError_ err else pure xs+action ?+ err = action <&> atLeastOne ? err -- | Succeeds if there is exactly one element, returning it; else throws the error built--- from the 'CardinalityError'.+-- from the 'CardinalityError'. Sugar for @xs <&> 'exactlyOne' ?? toErr@. (?!) :: forall es e t a. (Error e :> es, Foldable t) => Eff es (t a) -> (CardinalityError (t a) -> e) -> Eff es a-(?!) action toErr = do- xs <- action- case toList xs of- [] -> throwError_ $ toErr IsEmpty- [x] -> pure x- _ -> throwError_ $ toErr $ TooMany xs+action ?! toErr = action <&> exactlyOne ?? toErr -- | Succeeds if empty, returning @()@; else throws the error built from the collection.+-- Sugar for @xs <&> 'noneAtAll' ?? toErr@. (?∅) :: 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)+action ?∅ toErr = action <&> noneAtAll ?? toErr -- | ASCII alias for '?∅'. (?@) :: forall es e t a. (Error e :> es, Foldable t) => Eff es (t a) -> ((t a) -> e) -> Eff es () (?@) = (?∅) -infixl 1 ??, ?, ?!, ?+, ?∅, ?@+-- | Succeeds if at least one element succeeds, returning the successes; else throws the+-- error built from every failure (none if the collection was empty). Unlike '?+' it asks+-- whether an element succeeded, not whether there is one. On a nested base type such as+-- @Maybe (Maybe a)@ the outer layer is the collection and the inner one is checked.+-- Sugar for @xs <&> 'anySucceeds' ?? toErr@.+(?∃) :: forall es e t a. (Error e :> es, Foldable t, Bifurcate a)+ => Eff es (t a) -> ([CErr a] -> e) -> Eff es [CRes a]+action ?∃ toErr = action <&> anySucceeds ?? toErr++infixl 1 ??, ?, ?!, ?+, ?∅, ?@, ?∃
src/Railroad/Bifurcate.hs view
@@ -6,19 +6,37 @@ -- | -- The 'Bifurcate' class, which splits a structure (@Bool@, @Maybe@, @Either@, @Validation@,--- traversables of them) into a failure and a success case, and the operators that never--- throw. The throwing operators are in "Railroad" and "Railroad.MonadError".+-- traversables of them) into a failure and a success case, the classifiers built on it, and+-- the operators that never throw. Nothing here depends on how a failure is thrown; the+-- throwing operators are in "Railroad" (@effectful@) and "Railroad.MonadError" (@mtl@). module Railroad.Bifurcate- ( Bifurcate (..), CErr, CRes+ ( -- * The class+ Bifurcate (..), CErr, CRes+ -- * Classifiers+ -- $classifiers+ , neg, tagBy, partitionBifurcate, successes, failures, anySucceeds+ -- * Cardinality+ , CardinalityError (..), cardinalityErr, atLeastOne, exactlyOne, noneAtAll+ -- * Operators that never throw+ -- $operators , (?>), (??~), (?~), (?|), (?|<>), (?<*) ) where import Data.Bifunctor (first) import Data.Bool (bool)+import Data.Either (partitionEithers)+import Data.Foldable (toList) import Data.Kind (Type) import Data.Validation (Validation (..)) +-- $classifiers+-- A classifier is a plain function into 'Either'. It needs no functor and never throws:+-- apply it with @\<&\>@ and finish with any operator, e.g. @xs \<&\> 'exactlyOne' ?? f@. +-- $operators+-- These work in any 'Functor' or 'Monad' and never throw. Finish a chain with @?@ or @??@+-- from "Railroad" or "Railroad.MonadError" to turn a remaining failure into a throw.+ -- | 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@@ -75,10 +93,74 @@ bifurcate = bifurcate . sequenceA +-- | Negation: swaps failure and success. A verb after it acts on a value that was meant to+-- fail, and the error mapper sees what succeeded instead.+--+-- > lookupUser email <&> neg ?? const err409 -- Nothing passes, Just _ is a 409+--+-- On a traversable the failure is "some element failed", so @neg@ is the negation of the+-- all-succeed reading: @xs <&> neg ? e@ passes exactly when @xs ? e@ would throw.+-- @neg . atLeastOne@ is @noneAtAll@, and @neg (neg x)@ is @'bifurcate' x@.+neg :: Bifurcate a => a -> Either (CRes a) (CErr a)+neg = either Right Left . bifurcate++-- | The classifier behind @(?>)@: @Right@ if the value satisfies the predicate, else @Left@+-- carrying it.+tagBy :: (a -> Bool) -> a -> Either a a+tagBy predicate x = if predicate x then Right x else Left x++-- | Splits a collection of results into its failures and its successes, in order.+partitionBifurcate :: (Foldable t, Bifurcate a) => t a -> ([CErr a], [CRes a])+partitionBifurcate = partitionEithers . map bifurcate . toList++-- | The success values of a collection of results; the failures are dropped.+successes :: (Foldable t, Bifurcate a) => t a -> [CRes a]+successes = snd . partitionBifurcate++-- | The failure values of a collection of results.+failures :: (Foldable t, Bifurcate a) => t a -> [CErr a]+failures = fst . partitionBifurcate++-- | Classifies a collection of results as having at least one success. The success carries+-- every success value; the failure carries every failure value (none if the collection+-- was empty): @xs <&> anySucceeds ?? f@.+anySucceeds :: (Foldable t, Bifurcate a) => t a -> Either [CErr a] [CRes a]+anySucceeds xs = case partitionBifurcate xs of+ (es, []) -> Left es+ (_, rs) -> Right rs++-- | Why a collection was not a single element: empty, or too many (carrying it).+data CardinalityError ta = IsEmpty | TooMany ta+ deriving (Eq, Show)++-- | Fold for 'CardinalityError': one result per case.+cardinalityErr :: e -> (ta -> e) -> CardinalityError ta -> e+cardinalityErr onEmpty onTooMany = \case+ IsEmpty -> onEmpty+ TooMany xs -> onTooMany xs++-- | Classifies a collection as non-empty. The failure carries nothing, so the result is+-- @Bifurcate@ with @()@ as the error info: @xs <&> atLeastOne ? e@ is @xs ?+ e@.+atLeastOne :: Foldable t => t a -> Maybe (t a)+atLeastOne xs = if null xs then Nothing else Just xs++-- | Classifies a collection as having exactly one element, returning it. The failure says+-- whether there were none or too many: @xs <&> exactlyOne ?? f@ is @xs ?! f@.+exactlyOne :: Foldable t => t a -> Either (CardinalityError (t a)) a+exactlyOne xs = case toList xs of+ [] -> Left IsEmpty+ [x] -> Right x+ _ -> Left (TooMany xs)++-- | Classifies a collection as empty. The failure carries the collection:+-- @xs <&> noneAtAll ?? f@ is @xs ?∅ f@.+noneAtAll :: Foldable t => t a -> Either (t a) ()+noneAtAll xs = if null xs then Right () else Left xs+ -- | Tags the value: @Right@ if it satisfies the predicate, else @Left@ carrying it. Finish -- with @?@ or @??@ (the mapper sees the rejected value): @m ?> p ? err@. (?>) :: forall m a. Functor m => m a -> (a -> Bool) -> m (Either a a)-action ?> predicate = (\x -> if predicate x then Right x else Left x) <$> action+action ?> predicate = tagBy predicate <$> action -- | Unwraps the success case, or recovers with a value computed from the error info. (??~) :: forall m a. (Functor m, Bifurcate a) => m a -> (CErr a -> CRes a) -> m (CRes a)
− src/Railroad/Cardinality.hs
@@ -1,17 +0,0 @@-{-# LANGUAGE LambdaCase #-}---- |--- The error info of the cardinality operators (@?!@ in "Railroad" and "Railroad.MonadError"):--- why a collection was not a single element.-module Railroad.Cardinality- ( CardinalityError (..), cardinalityErr- ) where---- | Why a collection was not a single element: empty, or too many (carrying it).-data CardinalityError ta = IsEmpty | TooMany ta---- | Fold for 'CardinalityError': one result per case.-cardinalityErr :: e -> (ta -> e) -> CardinalityError ta -> e-cardinalityErr onEmpty onTooMany = \case- IsEmpty -> onEmpty- TooMany xs -> onTooMany xs
src/Railroad/MonadError.hs view
@@ -6,15 +6,13 @@ module Railroad.MonadError ( module Railroad.Bifurcate- , module Railroad.Cardinality , collapse- , (??), (?), (?+), (?!), (?∅), (?@)+ , (??), (?), (?+), (?!), (?∅), (?@), (?∃) ) where import Control.Monad.Except (MonadError (..))-import Data.Foldable (toList)+import Data.Functor ((<&>)) import Railroad.Bifurcate-import Railroad.Cardinality -- | Unwraps the success case, or throws the failure mapped by the function. collapse :: (MonadError e m, Bifurcate f)@@ -34,33 +32,35 @@ -- | Succeeds if non-empty, returning the collection; else throws the constant error. -- Chain it before @?@ on a traversable, which an empty collection passes.+-- Sugar for @xs <&> 'atLeastOne' ? err@. (?+) :: 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+action ?+ err = action <&> atLeastOne ? err -- | Succeeds if there is exactly one element, returning it; else throws the error built--- from the 'CardinalityError'.+-- from the 'CardinalityError'. Sugar for @xs <&> 'exactlyOne' ?? toErr@. (?!) :: forall m e t a. (MonadError e m, Foldable t) => m (t a) -> (CardinalityError (t a) -> e) -> m a-(?!) action toErr = do- xs <- action- case toList xs of- [] -> throwError $ toErr IsEmpty- [x] -> pure x- _ -> throwError $ toErr $ TooMany xs+action ?! toErr = action <&> exactlyOne ?? toErr -- | Succeeds if empty, returning @()@; else throws the error built from the collection.+-- Sugar for @xs <&> 'noneAtAll' ?? toErr@. (?∅) :: 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)+action ?∅ toErr = action <&> noneAtAll ?? toErr -- | ASCII alias for '?∅'. (?@) :: forall m e t a. (MonadError e m, Foldable t) => m (t a) -> (t a -> e) -> m () (?@) = (?∅) -infixl 1 ??, ?, ?!, ?+, ?∅, ?@+-- | Succeeds if at least one element succeeds, returning the successes; else throws the+-- error built from every failure (none if the collection was empty). Unlike '?+' it asks+-- whether an element succeeded, not whether there is one. On a nested base type such as+-- @Maybe (Maybe a)@ the outer layer is the collection and the inner one is checked.+-- Sugar for @xs <&> 'anySucceeds' ?? toErr@.+(?∃) :: forall m e t a. (MonadError e m, Foldable t, Bifurcate a)+ => m (t a) -> ([CErr a] -> e) -> m [CRes a]+action ?∃ toErr = action <&> anySucceeds ?? toErr++infixl 1 ??, ?, ?!, ?+, ?∅, ?@, ?∃
test/Railroad/BifurcateSpec.hs view
@@ -4,7 +4,7 @@ import Control.Monad.State (modify, runState) import Data.Bifunctor (first)-import Data.Either (isLeft)+import Data.Either (isLeft, isRight) import Data.Functor.Identity import Data.Maybe (listToMaybe) import Data.Proxy (Proxy (..))@@ -81,3 +81,92 @@ it "[Either], [Either]" $ law (Proxy @[Either [Int] Int]) (Proxy @[Either [Int] Int]) it "is associative" $ property $ \(a :: Either [Int] Int) (b :: Either [Int] Int) (c :: Either [Int] Int) -> runIdentity ((pure a ?|<> pure b) ?|<> pure c) === runIdentity (pure a ?|<> (pure b ?|<> pure c))++ describe "tagBy" $ do+ it "keeps the value on both sides" $ property $ \(p :: Fun Int Bool) (x :: Int) ->+ either id id (tagBy (applyFun p) x) === x+ it "is Right exactly when the predicate holds" $ property $ \(p :: Fun Int Bool) (x :: Int) ->+ isRight (tagBy (applyFun p) x) === applyFun p x++ describe "partitionBifurcate, successes, failures" $ do+ let laws :: forall a. (Shape a, Bifurcate [a], CErr [a] ~ CErr a, CRes [a] ~ [CRes a])+ => Proxy a -> Property+ laws _ = property $ \(xs :: [a]) (ys :: [a]) ->+ let (es, rs) = partitionBifurcate xs+ in (length es + length rs === length xs)+ .&&. (partitionBifurcate (xs ++ ys) === (es <> failures ys, rs <> successes ys))+ .&&. (isRight (bifurcate xs) === null es)+ .&&. (either (const True) (== rs) (bifurcate xs))+ .&&. (successes xs === rs)+ .&&. (failures xs === es)+ single :: forall a. Shape a => Proxy a -> Property+ single _ = property $ \(x :: a) ->+ (successes [x] === either (const []) pure (bifurcate x))+ .&&. (failures [x] === either pure (const []) (bifurcate x))+ it "Bool" $ laws (Proxy @Bool)+ it "Maybe" $ laws (Proxy @(Maybe Int))+ it "Either" $ laws (Proxy @(Either Int Int))+ it "Validation" $ laws (Proxy @(Validation [Int] Int))+ forShapes "a singleton splits as its element does" single++ describe "anySucceeds" $ do+ let law :: forall a. (Shape a) => Proxy a -> Property+ law _ = property $ \(xs :: [a]) ->+ let r = anySucceeds xs+ in (isRight r === any (isRight . bifurcate) xs)+ .&&. (r === (if any (isRight . bifurcate) xs then Right (successes xs) else Left (failures xs)))+ .&&. (either length (const 0) r === (if any (isRight . bifurcate) xs then 0 else length xs))+ it "Bool" $ law (Proxy @Bool)+ it "Maybe" $ law (Proxy @(Maybe Int))+ it "Either" $ law (Proxy @(Either Int Int))+ it "Validation" $ law (Proxy @(Validation [Int] Int))+ it "fails on an empty collection with no failures" $+ anySucceeds ([] :: [Maybe Int]) `shouldBe` Left []++ describe "neg" $ do+ it "Bool and Maybe swap to the unit failure" $ do+ neg True `shouldBe` Left ()+ neg False `shouldBe` Right ()+ neg (Just 'x') `shouldBe` Left 'x'+ neg (Nothing :: Maybe Char) `shouldBe` Right ()+ forShapes "swaps failure and success" $ \(_ :: Proxy a) -> property $+ \(x :: a) -> neg x === either Right Left (bifurcate x)+ forShapes "is an involution: neg (neg x) = bifurcate x" $ \(_ :: Proxy a) -> property $+ \(x :: a) -> neg (neg x) === bifurcate x+ it "of a traversable passes exactly when some element failed" $ property $+ \(xs :: [Either Int Int]) -> isRight (neg xs) === any (isLeft . bifurcate) xs+ it "carries the first failure of a traversable" $ property $ \(xs :: [Either Int Int]) ->+ neg xs === (case [e | Left e <- xs] of { e : _ -> Right e; [] -> Left [a | Right a <- xs] })+ it "of atLeastOne is noneAtAll" $ property $ \(xs :: [Int]) ->+ neg (atLeastOne xs) === noneAtAll xs+ it "turns anySucceeds into all-failed" $ property $ \(xs :: [Either Int Int]) ->+ isRight (neg (anySucceeds xs)) === all (isLeft . bifurcate) xs++ describe "atLeastOne" $ do+ it "classifies a list by emptiness, keeping the list" $ property $ \(xs :: [Int]) ->+ atLeastOne xs === (if null xs then Nothing else Just xs)+ it "works on any Foldable" $ do+ atLeastOne (Just 'a') `shouldBe` Just (Just 'a')+ atLeastOne (Nothing :: Maybe Char) `shouldBe` Nothing++ describe "exactlyOne" $ do+ it "returns the element of a singleton, else says why not" $ property $ \(xs :: [Int]) ->+ exactlyOne xs === (case xs of { [] -> Left IsEmpty; [x] -> Right x; _ -> Left (TooMany xs) })+ it "works on any Foldable" $ do+ exactlyOne (Just 'a') `shouldBe` Right 'a'+ exactlyOne (Nothing :: Maybe Char) `shouldBe` Left IsEmpty++ describe "noneAtAll" $ do+ it "succeeds on an empty collection, else returns it as the failure" $ property $ \(xs :: [Int]) ->+ noneAtAll xs === (if null xs then Right () else Left xs)++ describe "the three agree with each other" $ do+ it "exactlyOne reports IsEmpty exactly when noneAtAll succeeds" $ property $ \(xs :: [Int]) ->+ (exactlyOne xs == Left IsEmpty) === (noneAtAll xs == Right ())+ it "a list of one element satisfies atLeastOne and exactlyOne but not noneAtAll" $ property $ \(x :: Int) ->+ (atLeastOne [x], exactlyOne [x], noneAtAll [x]) === (Just [x], Right x, Left [x])++ describe "CardinalityError" $ do+ it "cardinalityErr is the eliminator" $ property $ \(n :: Int) (xs :: [Int]) ->+ (cardinalityErr n length (IsEmpty :: CardinalityError [Int]) === n)+ .&&. (cardinalityErr n length (TooMany xs) === length xs)
test/Railroad/MonadErrorSpec.hs view
@@ -6,6 +6,7 @@ import Control.Monad.Except import Control.Monad.State (modify, runState) import Data.Bifunctor (first)+import Data.Either (isRight) import Data.Functor ((<&>)) import Data.Functor.Identity import Data.Proxy (Proxy (..))@@ -61,6 +62,21 @@ 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"++ describe "?∃ (at least one success)" $ do+ it "returns the successes, or throws every failure" $ property $ \(xs :: [Either Int Int]) ->+ runMonadError (pure xs ?∃ show)+ === (if any isRight xs then Right [a | Right a <- xs] else Left (show [e | Left e <- xs]))+ it "throws on an empty collection, with no failures" $+ runMonadError (pure ([] :: [Maybe Int]) ?∃ show) `shouldBe` Left "[]"+ it "passes the successes of a Maybe collection and throws unit failures" $ do+ runMonadError (pure [Just 1, Nothing, Just (3 :: Int)] ?∃ show) `shouldBe` Right [1, 3]+ runMonadError (pure [Nothing, Nothing :: Maybe Int] ?∃ show) `shouldBe` Left "[(),()]"+ it "throws where ?+ passes: every element failed" $ do+ runMonadError (pure [Left 1, Left 2 :: Either Int Int] ?+ "none") `shouldSatisfy` isRight+ runMonadError (pure [Left 1, Left 2 :: Either Int Int] ?∃ show) `shouldBe` Left "[1,2]"+ it "chains left to right with the other operators" $ do+ runMonadError (pure (Right [Just 1, Nothing] :: Either String [Maybe Int]) ? "db" ?∃ show) `shouldBe` Right [1] describe "README" $ do it "runs the first example" $ do
test/RailroadSpec.hs view
@@ -3,6 +3,7 @@ module RailroadSpec where import Data.Bifunctor (first)+import Data.Either (isRight) import Data.Functor ((<&>)) import Data.Proxy (Proxy (..)) import Effectful@@ -61,3 +62,18 @@ 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"++ describe "?∃ (at least one success)" $ do+ it "returns the successes, or throws every failure" $ property $ \(xs :: [Either Int Int]) ->+ runRail (pure xs ?∃ show)+ === (if any isRight xs then Right [a | Right a <- xs] else Left (show [e | Left e <- xs]))+ it "throws on an empty collection, with no failures" $+ runRail (pure ([] :: [Maybe Int]) ?∃ show) `shouldBe` Left "[]"+ it "passes the successes of a Maybe collection and throws unit failures" $ do+ runRail (pure [Just 1, Nothing, Just (3 :: Int)] ?∃ show) `shouldBe` Right [1, 3]+ runRail (pure [Nothing, Nothing :: Maybe Int] ?∃ show) `shouldBe` Left "[(),()]"+ it "throws where ?+ passes: every element failed" $ do+ runRail (pure [Left 1, Left 2 :: Either Int Int] ?+ "none") `shouldSatisfy` isRight+ runRail (pure [Left 1, Left 2 :: Either Int Int] ?∃ show) `shouldBe` Left "[1,2]"+ it "chains left to right with the other operators" $ do+ runRail (pure (Right [Just 1, Nothing] :: Either String [Maybe Int]) ? "db" ?∃ show) `shouldBe` Right [1]