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
do [ (_: 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. 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, letM, pure,
bind, run, and handle into scope immediately. Division and
types are one level deeper, under operators and types
respectively.
fx.sugar
├── do, 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:
let inherit (fx.sugar) do letM;
in do [
(_: 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: sequence of steps
do takes a list of functions, each of which receives the previous
step's value and returns the next computation. The first step gets
null:
do [
(_: pure 1)
(x: pure (x + 1))
(x: pure (x * 10))
]
# runs to 20
Empty lists produce pure null. Singletons are equivalent to calling
the one step on null. The argument binding is positional: (n: ...)
means "the previous step's value is bound to n." If you don't need
it, use _.
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 port number, written without sugar:
let inherit (fx.types) Int refined;
in refined "Port" Int (x: x >= 0 && x <= 65535)
and with sugar:
let inherit (fx.sugar.types) Int;
in Int (x: x >= 0) (x: x <= 65535)
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 Int;
in refined "Port" Int (x: x >= 0 && x <= 65535)
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 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
do and letM are the default. 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.