Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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

Stars

7 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages