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:
bind state.get (n:
bind (state.put (n + 1)) (_:
bind state.get (n2:
pure n2)))
and with sugar, two forms:
# 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:
# 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, AnyThe combinator-only form is safe under every Nix dialect. No operator
magic, no with, no chance of surprising anyone:
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:
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:
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:
(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:
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:
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:
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:
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.
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:
let inherit (fx.types) String refined;
in refined "TargetClass" String
(x: builtins.elem x [ "module" "file" "package" "check" ])
and with sugar:
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:
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:
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:
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:
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:
# 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:
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.