pipeand vic's dendritic libs made for you with Love++ and AI--. If you like my work, consider sponsoring
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 100Calling 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;
}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 deferredYou 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]; # ✗ meaninglessThe 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 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 enddo [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.
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"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"]; }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)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
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.
# flake.nix
inputs.pipe.url = "github:denful/pipe";
# use pipe = inputs.pipe.lib
pipe [ ops... ] datapipe uses 0-based versioning