Skip to content

denful/pipe

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Sponsor Vic Ask DeepWiki Dendritic Nix License CI Status

pipe and vic's dendritic libs made for you with Love++ and AI--. If you like my work, consider sponsoring

A composable version of lib.pipe (|>) for freer monads

This tiny library provides generic monadic do pipe and combinators for Nix.

# As a replacement for lib.pipe (|>)
let
  x = pipe [ (n: n * 2) (n: n + 1) ];
  y = pipe [ (n: n / 2) (n: n - 1) ];
  z = pipe [ x y ]; # subpipes are first class
in z 100

Calling pipe with a list of operations is actually a do pipeline over the Id-monad. All Nix values work on Id without any wrapping.

You can also create your own monad instances.

Provided pipe.Id is defined like:

  # Identity monad — the simplest possible monad, where M a = a.
  Id = pipe {
    pure = x: x;
    bind = m: f: f m;
    isM = x: true; # Every Nix value is a valid Id value
  };

And pipe.Maybe is:

  Maybe = pipe {
    # constructors
    nothing = { nothing = null; };   
    just = just: { inherit just; };

    pure = Maybe.just;
    bind = m: f: if m ? just then f m.just else m;
    isM = v: v ? nothing || v ? just;
  };

Pipelines can be generic on the Monad type and can be run on different instances.

let
  pipeline = [ (n: n * 2) (x: x + 1) ];
in {
  x = Id.do    pipeline 20;
  y = Maybe.do pipeline 20;
}

The problem with lib.pipe

lib.pipe (and |> experimental pipe-operator) is not composable. It consumes the seed value immediately:

lib.pipe x [f g h]   # h (g (f x)) — x is required first, not deferred

You cannot build a pipeline from sub-pipelines without already holding a value:

# You want to do this — but you can't:
let p1 = lib.pipe [f g];   # ✗ lib.pipe needs the seed
    p2 = lib.pipe [h i];
    p3 = lib.pipe [p1 p2]; # ✗ meaningless

The root cause: lib.pipe has type a → [a→b, b→c, …] → z. The seed is baked in. The result is a value, not a function.


pipe.do — the composable pipeline

pipe exposes do(among other operators), which folds a list of Kleisli arrows into a single Kleisli arrow using foldl' kleisli pure:

do = arrows: builtins.foldl' kleisli pure arrows;
#   type: [a→M b, b→M c, …] → (a → M z)

The seed is deferred. The result is a function. Pipelines compose:

let p1 = m.do [f g];       # a → M c
    p2 = m.do [h i];       # c → M e
    p3 = m.do [p1 p2];     # a → M e  — no seed needed
in p3 seed                 # apply once, at the end

do [f g h] x and lib.pipe x [f g h] produce the same result under the Identity monad. The difference is purely structural: do gives you a first-class function you can pass around, store, and compose further.


Other monads (not included) — same interface, different semantics

Result — structured errors

ok  = x: { tag = "ok";  value = x; };
err = e: { tag = "err"; error = e; };

m = pipe {
  pure = ok;
  bind = r: f: if r.tag == "err" then r else f r.value;
  isM  = v: builtins.isAttrs v && (v.tag or null == "ok" || v.tag or null == "err");
};

parseConfig = m.do [
  (raw: if raw ? host  then ok raw  else err "missing: host")
  (raw: if raw ? port  then ok raw  else err "missing: port")
  (raw: ok (raw // { port = builtins.fromJSON (toString raw.port); }))
];
# parseConfig { host = "x"; port = "8080"; }  =>  ok { host = "x"; port = 8080; }
# parseConfig { host = "x"; }                 =>  err "missing: port"

Writer — accumulate a log alongside results

tell  = msg: x: { value = x; log = [ msg ]; };
m = pipe {
  pure = x: { value = x; log = []; };
  bind = w: f: let r = f w.value; in { value = r.value; log = w.log ++ r.log; };
  isM  = v: builtins.isAttrs v && v ? value && v ? log;
};

m.do [
  (cfg: tell "loaded config"  cfg)
  (cfg: tell "validated"      (validate cfg))
  (cfg: tell "normalised"     (normalise cfg))
] rawConfig
# => { value = <final cfg>; log = ["loaded config" "validated" "normalised"]; }

nix-effects freer monad — effects as data

m = pipe {
  inherit (fx.kernel) pure bind;
  isM = fx.comp.isComp;
};

# m.do composes effectful Kleisli arrows — the pipeline is a computation tree,
# not a value. Swap the handler at the call site without touching the pipeline.
pipeline = m.do [
  (fx.send "load")
  (x: x * 2)
  (fx.send "store")
];

fx.run { handlers = { load = ...; store = ...; }; } (pipeline null)

Full API

bind  pure  isM          — the three primitives you supply

map                      — fmap:  (a→b) → M a → M b
join                     — flatten: M (M a) → M a
void                     — discard result, return pure null
seq                      — (>>) sequence two, keep second

kleisli                  — (>=>) compose two Kleisli arrows
do                       — composable: [K] → (a → M b)   ← the point

sequence                 — [M a] → M [a]
mapM                     — (a → M b) → [a] → M [b]
forM                     — [a] → (a → M b) → M [b]

when                     — Bool → M a → M a  (or pure null)
unless                   — Bool → M a → M a  (or pure null)

lift                     — a|M a → M a       (needs isM)
liftA2                   — (a→b→c) → M a → M b → M c
ap                       — M (a→b) → M a → M b

Why do = foldl' kleisli pure works

pure is the Kleisli identity: kleisli pure f ≡ f and kleisli f pure ≡ f. Folding over an empty list returns pure, which is x: pure x — the identity arrow. Kleisli composition is associative, so do [p1 p2] where each pN is itself a do-pipeline produces the same result as flattening all arrows into one list. Sub-pipelines are just arrows; the monad laws guarantee the fold is coherent regardless of how you bracket it.


Install

# flake.nix
inputs.pipe.url = "github:denful/pipe";

# use pipe = inputs.pipe.lib
pipe [ ops... ] data

pipe uses 0-based versioning

About

A composable version of Nix `lib.pipe` (`|>`) for any freer monad

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

  •  

Packages

 
 
 

Contributors