Navigation

Sugar

nix-effects ships an opt-in syntax layer. The kernel doesn't import it, nothing in the effect interpreter depends on it, and removing it leaves the library unchanged. What it buys is readability. A three-step state computation without sugar:

nix
bind state.get (n:
  bind (state.put (n + 1)) (_:
    bind state.get (n2:
      pure n2)))

and with sugar, two forms:

nix
# Combinator (sequence effects, discard intermediates)
steps [ (_: state.get) (n: state.put (n + 1)) (_: state.get) ]

# Operator
state.get / (n: state.put (n + 1)) / (_: state.get)

Both evaluate to the same value under the state handler. do is the companion combinator: rather than producing a Comp directly it returns a Kleisli arrow (a -> M b) that you apply to a seed — trading one keystroke ((... ) null) for composability, point-free use, and auto-lifting of plain functions. The two are covered side-by-side in Effect combinators.

A third form — letM — applies to parallel effects whose results you want under named bindings:

nix
# Without sugar
bind (reader.asks (e: e.host)) (host:
  bind (reader.asks (e: e.port)) (port:
    pure "${host}:${toString port}"))

# With letM
letM {
  host = reader.asks (e: e.host);
  port = reader.asks (e: e.port);
} (b: pure "${b.host}:${toString b.port}")

letM evaluates its attrs independently and passes the result attrset to the continuation. Use it when the effects don't depend on each other's values.

Opting in

Sugar is a hybrid namespace. Effect combinators sit at the top level of fx.sugar, so with fx.sugar; brings do, steps, letM, pure, bind, run, and handle into scope immediately. Division and types are one level deeper, under operators and types respectively.

fx.sugar
  ├── do, steps, letM
  ├── pure, bind, map, seq, pipe, kleisli
  ├── run, handle
  ├── operators
  │     └── __div
  └── types
        ├── wrap
        └── Int, String, Bool, Float, Path, Null, Unit, Any

The combinator-only form is safe under every Nix dialect. No operator magic, no with, no chance of surprising anyone:

nix
let inherit (fx.sugar) steps letM;
in steps [
  (_: state.get)
  (n: state.put (n * 3))
  (_: state.get)
]

Adding / as left-associative bind turns long bind chains into pipelines:

nix
let inherit (fx.sugar.operators) __div;
in state.get / (s: state.put (s + 7)) / (_: state.get)

For a file that's mostly computation, reach for with fx.sugar; and pair it with the __div inherit:

nix
let
  inherit (fx.sugar.operators) __div;
in with fx.sugar;
  state.get / (s: state.put (s * 2)) / (_: state.get)

The division operator is always nested under operators. with fx.sugar; alone will not activate / — you have to reach for operators explicitly. That nesting is the entire reason the namespace is hybrid.

Effect combinators

do: composable Kleisli arrow

do takes a list of functions and returns a Kleisli arrow (a -> M b). Apply it to a seed value to obtain the computation:

nix
(do [
  (x: x + 1)            # plain function — auto-lifted via `pure`
  (x: pure (x * 10))    # already monadic — passes through
]) 1
# runs to 20

Three properties follow from returning an arrow rather than a Comp directly.

Composable. Two pipelines glue via kleisli without re-wrapping:

nix
kleisli (do [ f g ]) (do [ h i ])  ==  do [ f g h i ]

Data-last. The seed is the final argument, so do slots into map point-free:

nix
map (do [ validate enrich ]) userIds

Auto-lifting. Each step may be plain (a -> b) or monadic (a -> M b). The runtime dispatches via isComp: a Comp result passes through bind, anything else is wrapped in pure. Pure and effectful steps mix freely without manual lifting.

The empty list gives the identity arrow x: pure x; the singleton list applies its single step.

steps: sequence of effects

steps is the original do semantics, renamed. It takes a list of functions, threads each through bind, and returns a Comp directly:

nix
steps [
  (_: state.get)
  (n: state.put (n + 1))
  (_: state.get)
]

The seed is pure null, so the first step receives null. Empty lists produce pure null. Use steps when the intent is "sequence these effects" rather than "thread a value through a pipeline" — analogous to Haskell's sequence_. When the first step is producer- shaped (_: pure x), the leading null is harmless boilerplate; for anything more elaborate, reach for do so the seed is explicit.

letM: named results

letM collects an attrset of computations, evaluates each one, and hands the result attrset to a continuation. The Reader-pattern example from the test suite:

nix
letM {
  host = reader.asks (e: e.host);
  port = reader.asks (e: e.port);
} (b: pure "${b.host}:${toString b.port}")
# runs to "example.com:443"

The continuation receives { host; port; }. When a computation and its continuation don't need sequencing by intermediate values but do need named results in scope, letM is cleaner than nested bind.

__div: operator-style bind

__div is a magic attribute name. When both operands of / are non-numeric and __div is lexically in scope, Nix dispatches the operator through it. fx.sugar.operators.__div is fx.bind under another name.

nix
let inherit (fx.sugar.operators) __div;
in state.get / (n: pure (n + 1)) / (n: pure (n * 2))

The form is left-associative: a / f / g is bind (bind a f) g. This matches the usual reading of a pipeline.

Re-exports

For convenience, fx.sugar re-exports pure, bind, map, seq, pipe, kleisli, run, and handle verbatim from fx. with fx.sugar; gives you everything the effect layer exposes without a second inherit line.

Type sugar

Primitives and refinement

fx.sugar.types pre-wraps the eight zero-ary primitives — Int, String, Bool, Float, Path, Null, Unit, Any — with a __functor that builds a refinement when you apply a predicate. A target class, written without sugar:

nix
let inherit (fx.types) String refined;
in refined "TargetClass" String
  (x: builtins.elem x [ "module" "file" "package" "check" ])

and with sugar:

nix
let inherit (fx.sugar.types) String;
in String (x: builtins.elem x [ "module" "file" "package" "check" ])

Both produce a kernel-identical type. The difference is readability when you're composing several predicates.

Name cascading

Every refinement appends a ? to the base type's name. Repeated refinement cascades:

nix
let inherit (fx.sugar.types) Int;
  P0 = Int;                       # "Int"
  P1 = Int (x: x >= 0);           # "Int?"
  P2 = Int (x: x >= 0) (x: x < 10); # "Int??"
in builtins.toString P2
# "Int??"

The name is what shows up in error messages. If Int?? isn't descriptive enough, drop back to fx.types.refined and give the type an explicit name:

nix
let inherit (fx.types) refined String;
in refined "RendererClass" String
  (x: builtins.elem x [ "module" "file" "package" "check" ])

wrap for user-defined types

Types built with fx.types.mkType don't get sugar by default. Wrap them with fx.sugar.types.wrap to opt in:

nix
let
  inherit (fx.types) mkType hoas;
  inherit (fx.sugar.types) wrap;

  UserInt = mkType { name = "UserInt"; kernelType = hoas.int_; };
  Sugared = wrap UserInt;
in
  (Sugared (x: x > 0)).check 5   # true

Wrapping is purely additive. It only adds __functor (for refinement application) and __toString (for the name). The base type's kernel, check, description, universe, and every other field stay untouched — so a sugared type is interchangeable with the desugared original everywhere the kernel looks at it.

Sugar inside Record fields

Constructors like Record, ListOf, Maybe, and Either already consume a first argument — their schema. Wrapping them with __functor would collide with that call shape, so fx.sugar.types doesn't wrap them. It doesn't need to: a sugared field-type inside a Record schema composes for free, because Record reads only the kernel, which sugar preserves:

nix
let inherit (fx.types) Record;
    inherit (fx.sugar.types) Int String Bool;
in Record {
  age = Int (x: x >= 0);
  name = String (s: builtins.stringLength s > 0);
  active = Bool;
}

This Record has the same _kernel as the hand-refined version. The sugar is pushed into the schema, where it needs no special support from the constructor.

Caveats

A few details worth knowing before you reach for sugar.

+ can't be overloaded

Nix's + operator is ExprConcatStrings in the parser 1. The runtime dispatches on operand types (string, path, number) without consulting any magic attribute. There's no __plus to implement. Applied to two types, + will either concatenate (if they're strings), add (if they're numbers), or error out. It is not a hook.

For the same reason, there's no way to overload ==, <, or most other operators. Sugar uses what Nix already dispatches through: __functor for callable attrsets, __toString for string coercion, and __div for /.

with does not activate __div

Nix's / operator looks up __div by name in the enclosing lexical scope. It does not search with-scoped values. This surprises people who expect the two forms to be interchangeable:

nix
# Works:
let inherit (fx.sugar.operators) __div; in (state.get / f)

# Does NOT work — raises an arithmetic division error at runtime:
with fx.sugar.operators; (state.get / f)

The reason is that with only extends the free-variable lookup chain — it does not introduce __div as a bound name in the scope that / consults. The full-sugar form above wraps inherit (fx.sugar.operators) __div; in the same let that brings in the combinators for exactly this reason.

A witness test lives at tests/sugar-effects-test.nix under withOperatorsDoesNotActivateDiv. It asserts that with fx.sugar.operators; (6 / 2) == 3 — plain arithmetic, not __div dispatch.

Lix 2.92+ rejects __div shadow

Lix 2 deprecated the pattern of binding a name prefixed with __ that shadows a builtin-reserved operator slot. As of Lix 2.92, let inherit (ops) __div; in ... produces shadow-internal-symbols errors during parse. If your codebase targets Lix, stick to do, steps, and letM — they don't touch this mechanism.

This is not a CppNix limitation. __div works under CppNix 2.18+ and 2.31 (the release we test against). The Lix deprecation is a deliberate policy choice in that fork.

Scope pollution with with

with fx.sugar.operators; brings __div into the lookup chain as a value, not as an operator hook. As just noted, it won't make / dispatch to it. But it does make the name __div available for reference — a minor footgun if you were relying on shadowing. Prefer inherit over with for operator opt-in.

Name cascading versus explicit names

Chained refinements produce names like Int??. That's intentional ("you refined this twice") but not always helpful in error messages. If your domain has a real name, use fx.types.refined directly:

nix
let inherit (fx.types) refined Int;
    Even = refined "Even" Int (x: builtins.bitAnd x 1 == 0);
    Positive = refined "Positive" Int (x: x > 0);
    EvenPositive = refined "EvenPositive" Even (x: x > 0);
in EvenPositive.name   # "EvenPositive"

Sugar is for in-place predicates, not named types that outlive their definition.

Forward-compat notes

Sugar is strictly additive and never references anything the type system marks for retirement. Three commitments hold across future changes to the kernel and type modules.

Kernel preservation. A sugared type has the same _kernel as its base. Constructors (Record, ListOf, Maybe, Either, Variant) read only _kernel — so a sugared field is indistinguishable from a desugared one to every kernel consumer.

Refinement delegation. Sugar never constructs refined types directly. Every sugared T (pred) call goes through fx.types.refined, which is the user-facing API point guaranteed to survive kernel-internal reorganizations. If refined changes shape, sugar follows automatically.

No diagnostic emission. Sugar never builds values from src/diag/positions.nix or src/diag/error.nix. Error annotation happens via the base type's description and name, which propagate through refined without sugar-specific code. When the diagnostic layer gains structure, sugar will inherit it.

Active witness tests for each of these live in tests/sugar-compat-test.nix. Running the test file as part of nix flake check keeps the commitments observable.

When to reach for which form

steps is the default for "run these effects, in order, for their side effects." do is the default when a pipeline threads a value through several steps, when the pipeline needs to compose with another pipeline, or when you want point-free use under map and friends. letM covers the case where bound values are siblings rather than a left-to-right pipeline. Reach for __div when a pipeline has three or more obvious-effect steps and the parentheses are hurting readability. Reach for with fx.sugar; when you're writing a file that's mostly computation, not mostly plumbing.

For types, use fx.sugar.types for one-off refinements inside Record schemas. Drop back to fx.types.refined when the type deserves a name you'll reference elsewhere.


1 src/libexpr/parser.y in the Nix source, handling ExprConcatStrings.

2 Lix is a community fork of Nix. Relevant deprecation: shadow-internal-symbols in Lix 2.92 release notes.