Operators and expression semantics
This page is the reference for the operator layer of Flux: what each operator means, which
kinds it accepts, what kind it produces, and why each rule is the way it is. It covers the
dimensional algebra of + - * /, the two comparison families, logic on signal, the delay
x[n], the resample e @ clock, ranges, indexing and projection, UFCS method syntax, record
update with with, destructuring, the ? family of sugar, the _ placeholder, and the full
precedence table. How kinds are assigned to whole programs — synthesis, checking,
principality — is the subject of Inference; the lattice the
operators compute over is defined in Kinds.
New here? Start with Guide §5 — Kinds: types that carry meaning → for the teaching version of this algebra, then return here for the per-operator rules.
A note on the samples. Flux has no expression-statements, so a bare expression is not a program. The lines marked
✗on this page are therefore expression fragments: they exist to show what the kind rules refuse, not what the parser accepts. Every unmarked line is a legal statement.
One algebra, two systems
Two distinct systems cooperate under every expression, and keeping them apart is what makes the rules below predictable:
- The coercion lattice (A).
a ≤ bmeans “acoerces tobwithout lying”. Its join⊔unifies kinds at reconciliation sites — the branches of anif, the two operands ofnz, co-plotted series. Its meet⊓intersects constraints — the kind a parameter demands, overlappingoscbounds. - The operator algebra (B).
+ - * / < > ==are governed by a rule table — dimensional analysis in the tradition of unit-of-measure checkers — that computes the result kind of each application. It is not the join:price - price = level, whileprice ⊔ price = price.
Both systems report failure through a single error channel: any undefined rule of (B)
and any incompatibility of (A) produce ⊤. A ⊤ is a hard error only in a demanding
position (an operand, a plot target, an argument); a ⊤ bound to an intermediate name
that nothing consumes is a warning ([WarnTop]). The policy is detailed in
Inference.
Shared preamble, not drift. The two-system split and the affine substrate below are stated here as the ground the operators stand on; their canonical definition — the lattice
(K, ≤, ⊔, ⊓, ⊤, ⊥), the sorts, and the group of dimension exponents — lives in Kinds. This page owns only the per-operator rules that read off that ground.
The affine substrate. The price axis is a one-dimensional affine space: price is a
point on it, level is a vector (a displacement), ratio is a dimensionless scalar
centred on 1. Dimension itself is an abelian group of exponents over the generators
{P price, V volume, T time, I bar-index, rad angle}: * adds exponents, / subtracts
them. Two consequences fall out for free:
*and/are total on dimensions — there is always a result exponent vector;- only
+and-demand dimensional agreement, because adding a point to a point has no geometric meaning.
Figure — the affine substrate on one price axis:
price is a point, level is a vector (a
displacement), ratio is a dimensionless scalar centred on 1 — so point − point = vector,
point ± vector = point, and point + point names no place on the axis.
Why this rule exists. The affine reading is not decoration; it is what lets the checker say no to
close + rsi(close, 14)while acceptingclose + atr(14)— both are “price plus a number” to an untyped eye, but only one of them names a displacement on the price axis. Every rule below is an instance of this substrate, not a special case.
Addition and subtraction: + and -
+ and - require operands of the same dimension and then follow the affine rules:
| rule | reading | example | result |
|---|---|---|---|
pt(d) - pt(d) → vec(d) |
point − point = vector | close - open |
level |
pt(d) ± vec(d) → pt(d) |
point ± vector = point | close + atr(14) |
price |
vec ± vec → vec |
vectors add | atr(14) - atr(28) |
level |
ratio ± ratio → ratio |
scalars add | vortex(14).plus - vortex(14).minus |
ratio |
osc ± osc → propagated interval |
interval arithmetic on the claim | rsi(close,14) - 50 |
osc(-50,50) |
lit ± x → x |
a literal adopts the dimension | close + 10 |
price |
dims ≠ → ⊤ |
no affine meaning | close + rsi(close,14) |
✗ [ErrDim] |
spread = close - open // price − price : level
band = close + 2 * atr(14) // price + level : price
centered = rsi(close, 14) - 50 // osc(0,100) − lit : osc(−50,50)
close + rsi(close, 14) // ✗ [ErrDim] — point + dimensionless: no affine meaning
obv() + close // ✗ [ErrDim] — volume and price do not addThe osc line deserves a note: the bounds of an osc(lo,hi) are a presentation claim,
not a runtime invariant (only clamp makes a bound real), and ± propagates the claim by
interval arithmetic. Subtracting the midline from an oscillator therefore re-centres its
pane: rsi - 50 is drawn in a fixed [-50,50] pane with a midline at 0.
There is no arithmetic on signal — combine signals with and/or/not
(see Logic), count them with count(sig, n).
The one categorical overload: string concatenation
The only operator line outside the dimensional table is concatenation:
string + string → string. - * / and every mixed pair involving string remain outside
arithmetic and produce [ErrDim].
label = "px " + fmt.price(close) + " @ " + fmt.time(time) // string
"holdings: " + volume // ✗ [ErrDim] — format it: fmt.num(volume)Interpolation ("px {fmt.price(close)}") desugars to the same concatenation pipeline
(fmt.cat), so the two spellings are equivalent; chains of + fuse into a single bounded
write at compile time. See text for the string kind’s guarantees.
Affine combinations: the Σλ rule
A weighted sum of points is meaningful exactly when its coefficients say so. On an
expression normalised to Σ λᵢ · ptᵢ (coefficients are constant-folded literals, so x * 0.5
and x / 2 are the same λ, and (2*high + low + close) / 4 normalises fine), the checker
applies [Affine]:
Σλ = 1→ the point kind — a barycentre of prices is a price:(high + low) / 2 : price;Σλ = 0→ the vector kind — a zero-sum combination is a displacement:high - low : level;- anything else →
quantity+[WarnAffine]— dimension erased, probably a missing divisor.high + lowalone (Σλ = 2) draws the warning with a quick-fix: divide by 2 or use the pre-typed sourcehl2.
Two refinements make this rule cover real indicator algebra:
- A literal coefficient preserves the role:
k * stdev(close, 20)is still alevel, which is whysma(close, 20) + 2 * stdev(close, 20)types asprice + level → price— a volatility band lands on the chart, not in a pane. (The full bottom-up walkthrough of this exact expression, ending in its presentation decision, is drawn in Inference.) - The rule applies to composites, recursively, after inlining — not only to syntactic
OHLC averages.
dema = 2*ema(close,20) - ema(ema(close,20),20)hasΣλ = 1and types asprice; so doestema. Without recursive application, every multi-emacomposite would mistype aslevel.
The common OHLC barycentres are pre-typed sources (hl2, hlc3, ohlc4 : price), so the
90 % case never even exercises the rule.
Asset tags gate the combination. All the points in one affine combination must carry the
same asset tag, component by component, exactly as ± demands (see
below):
btcUsd = series("BTC-USD").close // price[BTC,USD]
btcEur = series("BTC-EUR").close // price[BTC,EUR]
mid = (btcUsd + btcEur) / 2 // ✗ [ErrDim] — quotes differ; convert one leg firstWhy this rule exists. Without the tag clause,
(btcUsd + btcEur) / 2would normalise toΣλ = 1and type-check as a validprice, silently averaging two currencies. The affine rule must not be a back door around the mixing ban.
Multiplication and division: * and /
* and / are total on dimensions: the result’s exponent vector is the sum
(respectively difference) of the operands’. The named kinds are the exponent vectors
you meet most often; any other combination is still a valid, plottable composed dimension
labelled by its exponents (eom : P²·V⁻¹ gets an auto pane — no warning, because a precise
composed dimension is not an erased one).
| rule | example | result |
|---|---|---|
D × ratio → D |
close * (volume / sma(volume, 20)) |
price |
lit × κ → κ |
2 * stdev(close, 20) |
level |
D × osc(0,1) → D |
close * bbPctB(close, 20) |
price |
price × volume → pv |
close * volume |
pv (money flow) |
price × price → P² |
close * close |
P², labelled pane |
price ÷ price → ratio |
close / close[1] |
ratio |
level ÷ level → ratio |
atr(14) / atr(28) |
ratio |
level ÷ price → ratio |
atr(14) / close |
ratio |
D ÷ ratio → D |
close / historicalVolatility(close, 20) |
price |
pv ÷ volume → price |
cum(close * volume) / cum(volume) |
price (vwap by hand) |
pv ÷ price → volume |
group law | volume |
level ÷ barspan → slope |
change(close, 20) / barssince(sig) |
slope = P·I⁻¹ |
rel = close / close[1] // ratio — centred on 1, own pane
flow = close * volume // pv — money flow, own pane
vwap0 = cum(close * volume) / cum(volume) // price — back on the chartNotes on the corners:
slopeis price-per-bar, not price-per-second: the x axis is ordinal, solevel ÷ barspan → slope = P·I⁻¹. Dividing alevelby adurationwould produceP·T⁻¹, a different (and rarely wanted) dimension.lrSlopereturnsslopefor this reason — derived from the group law, not decreed.sqrthalves exponents andpow(x, n)scales them, which is howstdev = sqrt(variance)types correctly; transcendentals (log,exp, trigonometry) demand dimensionless input —log(close / close[1])is fine; the compute pillar makeslog(close)an[ErrDim], with a quick-fix to the ratio.%is kept in v1, flagged for ratification. The grammar keeps%at the multiplicative tier; no catalogue kernel uses it, and its retention is one the grammar plan flags for final ratification rather than settling here. Its integer semantics on the integer aliaseslong(≡ decimal(18,0)) andlong128(≡ decimal(38,0)) are pinned (truncation toward zero; the remainder takes the dividend’s sign; division by zero andMIN / −1yieldna, never a trap). See compute.
Asset and currency tags under the operators
Kinds of price dimension carry an asset tag (B, Q[, @v]) — base, quote, optional
venue; volume carries the base alone, pv the quote alone. The tag’s structure and its
place in the lattice are defined in
Kinds; this section specifies only how the
operators act on it. They treat the tag with two different regimes, and the split is
deliberate:
±and comparisons gate on identity — mixing raises[ErrDim];*,/and the join widen — a differing component rises to its per-component top (⊤base,⊤quote), never an error.
btcUsd = series("BTC-USD").close // price[BTC,USD] — a quote-tagged close
d = btcUsd - btcUsd[1] // level[BTC,USD] — tag preserved through ±
btcUsd + ethUsd // ✗ [ErrDim] — bases differ: adding two assets
btcUsd + btcEur // ✗ [ErrDim] — quotes differ: adding two currencies
pnlUsd + pnlEur // ✗ [ErrDim] — pv[USD] + pv[EUR]: convert firstDivision derives exchange rates, and multiplication consumes them — the fx role rule:
btcUsd = series("BTC-USD").close // price[BTC,USD]
btcEur = series("BTC-EUR").close // price[BTC,EUR]
ethUsd = series("ETH-USD").close // price[ETH,USD]
fxUE = btcUsd / btcEur // fx[USD/EUR] — same base, quotes differ, concrete keys
cross = btcUsd / ethUsd // ratio — cross-base: relative strength, tag dropped
usd = btcEur * fxUE // price[BTC,USD] — shared quote cancels: (EUR/BTC)·(USD/EUR)
usd2 = btcEur / (1 / fxUE) // ÷ fx[EUR/USD] converts the same way
// triangular chaining — both operands are DERIVED; no primitive conjures a rate
btcGbp = series("BTC-GBP").close // price[BTC,GBP]
gbpEur = btcGbp / btcEur // fx[GBP/EUR]
usdGbp = fxUE / gbpEur // fx[USD/EUR] ÷ fx[GBP/EUR] → fx[USD/GBP]The complete edge list, in one place — the price ÷ price dispatch is a 2×2 on
(base equal?, quote equal?):
price[B,Q] ÷ price[B,Q] → ratio(both equal) ·price[B1,Q] ÷ price[B2,Q] → ratio(base differs) ·price[B1,Q1] ÷ price[B2,Q2] → ratio(both differ — no clean conversion exists, so the tag is dropped and you get a bare dimensionless number);price[B,Q1] ÷ price[B,Q2] → fx[Q1/Q2]— only when both quote keys are concrete statics; if either is⊤quote, the result drops to plainratio;price[B,Q1] * fx[Q2/Q1] → price[B,Q2](commutative) andprice[B,Q1] / fx[Q1/Q2] → price[B,Q2]— conversion; the shared quote must match string-for-string, otherwise the result widens toprice[B,⊤quote](never[ErrDim]: safety is±-only);levelandpvconvert by the same two edges;1 / fx[Q1/Q2] → fx[Q2/Q1]— the reciprocal; the numerator must be the literal1, matched before any coercion. A wrong direction is a distinct tag (fx[USD/EUR] ≠ fx[EUR/USD]), never a coercion;fx[A/Q] ÷ fx[B/Q] → fx[A/B]— triangular chaining;fx * fx → num(the pair annotation has no slot onnum);fx ± fx → ratio— a sum of rates is a magnitude, not a rate, so the pair annotation drops (kernels are different:ema(fxUE)keepsfx[USD/EUR]);price[B,Q] * volume[B] → pv[Q]— the base pairs off and is dropped: a money flow is a currency amount, base-agnostic, sopv[USD]of two different assets may be summed — that is portfolio arithmetic, not the asset-mixing bug;- cross-asset
*widens:price[B1,Q1] * price[B2,Q2] → P²[⊤base,⊤quote], never an error.
There is no fxRate(a, b) primitive: an fx value is derived by ÷ or arrives as a
series whose producer declares itself an fx feed. The full model — venue metadata,
toSource, mixed-currency series — is specified in
asset-currency.
Comparisons: two families, two scopes
A single “the join is defined” test would be the wrong gate: κ ⊔ κ = κ makes every
self-join admissible, including kinds with no order and no useful equality. Flux instead
scopes each comparison family to the sorts where its meaning is real. Both families
return signal.
Order: < > <= >= cross_up cross_down
Order comparisons are restricted to the scalar sort with a real total order, and the two
sides must be dimensionally compatible (κₐ ⊔ κᵦ ∉ {quantity, ⊤}):
- ordered: the numeric spine (
ratio,osc(·),signal,num) and the dimensioned antichain (price,level,volume,pv,time,duration,barindex,barspan,slope,angle); - excluded:
color,clock,string,record,vec,variant,ui—[ErrDim]/[ErrArg].string < stringis rejected deliberately: the core has no locale-dependent collation (a determinism exclusion — see text); diris excluded from order:{-1, 0, +1}is categorical-discrete, not a magnitude. Compare it with==only.
breakout = close > 30000 // price ⊔ lit = price → signal
overbought = rsi(close, 14) > 70 // osc vs lit → signal
golden = ema(close, 50) cross_up ema(close, 200) // signal — true on the crossing bar
close > rsi(close, 14) // ✗ [ErrDim] — price ⊔ osc = quantity: incomparable magnitudes
tf("1d") < tf("4h") // ✗ [ErrDim] — a clock has no order to compare on
superTrend(10, 3).dir > 0 // ✗ [ErrDim] — dir is categorical: write dir == 1cross_up / cross_down are infix comparison operators (never prefix calls): a cross_up b is true exactly on the bar where a moves from below-or-equal to above b.
They sit at the comparison tier and are non-associative like the rest of it.
Asset tags gate order too: operands must carry identical tags component-by-component —
price[BTC,USD] < price[BTC,EUR] is [ErrDim] even though the join of the two sides
would widen to a valid price. The fx pair annotation is exempt from the gate (it lives
on ratio, outside the asset axis): fxUE < fxGJ → signal compares two rates as plain
ratios.
Equality: == and !=
Equality reaches further than order — onto every sort whose equality is decidable and terminating:
- bit-equality for
string,dir,color— and ordinary scalar equality on the spine (close == open); - deep, decidable,
na-aware equality forrecord,vec,variant— component-by-component; two shapes that differ are[ErrDim]; - excluded:
clockanduiare consumed kinds, never compared —[ErrArg]; - scalar operands of differing dimensions or asset tags:
[ErrDim], exactly as for order; thefxpair annotation is again exempt.
st = superTrend(10, 3)
mark st.dir == 1 // dir compares by equality → signal
bb = bollinger(close, 20)
same = window(close, 4) == window(close, 4) // deep, na-aware equality on a vec → signal
tf("1d") == tf("1d") // ✗ [ErrArg] — clock is consumed, not comparedComparisons and na
Any comparison touching na yields na — never true, never false: na == x,
na < x, even na == na are all na. Absence is tested explicitly:
have = is_some(rsi(close, 14)) // signal — presence (not is_na(x))
gap = is_na(close[1]) // signal — absence (first bar)Why this rule exists. During warm-up an indicator is
nafor its first bars. Ifna > 70silently evaluated tofalse, every threshold rule would fire — or refuse to fire — on phantom data, and the bug would be invisible by construction. Forcingnathrough comparisons makes absence propagate to thesignal, whereis_na/is_somehandle it deliberately. The same discipline runs through the whole language:matchmust coverna, and window reducers propagate it.
Logic: and, or, not
The logical operators are defined on signal and only on signal
(signal × signal → signal); not is a prefix operator one tier tighter than and.
setup = close > ema(close, 50) and rsi(close, 14) < 30
flat = not in_session("09:30-16:00 America/New_York")
close and volume // ✗ [ErrArg] — `and` demands signals; neither operand is oneThere is no short-circuit effect to speak of — expressions are pure — so and/or are
plain boolean algebra on the {0,1} carrier, na-aware like everything else.
Delay: x[n]
x[n] reads the value of the stream x from n bars ago. The index must be a
constant-folded natural literal:
mom = close - close[10] // level — momentum as a displacement
prevH = macd(close).hist[1] // projection, then delay — postfix chain
close[-1] // ✗ [ErrCausal] — the future is not addressable
close[input(5)] // ✗ [ErrTotal] — delay must be a compile-time constantn ≥ 0;x[0]isxitself. A negative index is[ErrCausal]: Flux is no-repaint — a value, once produced for a step, never changes — and a read into the future is not a style violation but an unexpressible program.- A non-constant index is
[ErrTotal]: the delay depth sizes a ring buffer, and totality demands that all memory be bounded at compile time. - On the first
nbars,x[n]isna(warm-up), which the comparison rules above then carry safely.
The same bracket syntax on a vec receiver is not a delay but an element read
([Index]): slots[h.slot] takes a runtime ordinal index, and an out-of-bounds read
yields na rather than an error — the declared capacity already bounds the cost. The two
roles are distinguished by the receiver’s kind, never by guesswork; see
Indexing and projection.
Resample: e @ clock
@ is the eliminator of the clock kind: e @ c re-times the stream e onto the
clock c and preserves e’s kind ([At]). Clocks are first-class values — composable,
assignable, acceptable as input — and @ is how they are consumed:
calm = atr(14) < atr(50) // signal — a quiet-volatility regime
daily = ema(close @ "1d", 50) // MTF: a daily EMA under any chart timeframe
bricks = close @ renko(atrBox(14)) // representation change = clock change
c = if calm then tf("1d") else tf("4h")
trend = ema(close @ c, 20) // the clock itself was computed- The resample is causal: at any bar it sees the in-progress or last closed unit of
the coarser clock, never a future close.
[ErrCausal]guards the rest. - Constructors:
tf("1d"),renko(box),pnf(box, rev),range(r)— whereboxis alevel(the lattice forces it: a brick boundary isanchor + k·box, and onlyprice + levelis a price). - v1 allows one clock per series: composing two clocks (
renko(b) @ "1d") is rejected. - Grammar note: the right-hand side of
@is a restricted clock operand, sox @ "1d" + 1parses as(x @ "1d") + 1.
Clocks generalise “timeframe” — the full treatment (warm-up, alignment, live()) is in
time-and-state.
Ranges: a..b
.. builds a range and exists only in range positions — it is not an expression
operator, cannot be chained (non-associative), and never collides with . projection or
number literals (2..200 lexes as 2 .. 200).
bb = bollinger(close, 20)
fill bb.upper..bb.lower // band between two price streams
len = input(14, 2..200) // bounded parameter rangeThe two ends of a fill must live on the same ordered scalar axis — fill close..rsi(close, 14)
is [ErrDim] (see Inference for the
admissibility rules).
Indexing and projection
Projection e.f reads a field of a record ([Proj]). A missing field or a non-record
receiver is [ErrField] — with a nearest-name quick-fix:
bb = bollinger(close, 20) // record{upper, middle, lower : price}
plot bb.upper // price
bb.uper // ✗ [ErrField] — no such field; did you mean upper?Indexing v[i] on a vec<κ>[n] receiver reads an element ([Index]). The index may be
a runtime ordinal; the declared capacity n keeps the operation total, and out-of-bounds
reads yield na — never [ErrTotal], never a trap. This is the substrate of the slotmap
idiom (bounded collections with stable handles and na tombstones).
A dotted name can also be a qualified name rather than a projection — T.Ctor selects a
constructor of the declared variant T, and mod.f names an entry of an imported module.
Both resolve during name resolution, before [Proj] is ever considered. Which brings us
to the dot’s third meaning:
UFCS: close.ema(20).rsi(14)
Uniform Function Call Syntax: recv.f(args) is exactly f(recv, args) — the receiver
becomes the first argument. It is a semantic desugaring on the already-parsed tree, not a
grammar form, and it is what makes left-to-right pipelines read the way the data flows:
smooth = close.ema(20).rsi(14) // ≡ rsi(ema(close, 20), 14)
top5 = vec.topK(window(close, 100), (x) -> x, 5) // a namespace call — the dot means something else hereThe dot has three meanings, decided at compilation with one token of lookahead:
recv.fwithout(— a record field (bb.upper), rule[Proj];recv.f(wherefresolves to a function — a UFCS call, desugared tof(recv, …);mod.f/T.Ctorwhere the left side names a module or a declared variant — a qualified name, resolved before either of the above.
Tie-break: if recv has a field f and f names a function, recv.f(…) is the UFCS
call — a field is never callable, so the other reading could only be an error.
The trap is reading (2) where only (3) applies. UFCS reaches the functions in scope — the
prelude — and topK is not one of them: it lives in vec.*. So w.topK(…) is not a UFCS call
but a field projection on a vector, which is [ErrField]. Write vec.topK(w, …). The rule of
thumb is the one the completion menu already enforces: if the editor does not offer it after the
dot, it is not there.
Why this rule exists. UFCS adds zero grammar and zero semantics — the checker verifies
recvagainst the function’s first parameter exactly as if you had written the direct call — but it buys discoverability: afterclose., the editor can propose precisely the functions whose first parameter accepts aprice, filtered by kind. One mechanism, readable pipelines, kind-aware completion.
Named arguments compose with it: recv.f(x, mode: fast) binds mode by parameter name and
checks it like a positional (see Inference).
Record update: e with { … }
with produces a record identical to e except for the listed fields. It is
shape-preserving ([With]):
- every listed field must already exist —
[ErrField]otherwise (no silent field creation); - each value must coerce to the field’s declared kind —
[ErrArg]otherwise; - unlisted fields are carried over unchanged, and the result has exactly
e’s kind.
variant Phase { ask | suspense | revealed }
m = { score: 0, phase: ask }
m2 = m with { score: m.score + 1, phase: revealed }
m with { scrore: 0 } // ✗ [ErrField] — typo caught, nothing silently addedwith is a postfix suffix (same tier as call and projection), so it chains:
m with { a: 1 } with { b: 2 }.
Why this rule exists. Functional update without
withmeans rebuilding the whole record by hand — and a forgotten field is silent state loss. Shape preservation turns that class of bug into[ErrField]at compile time.
Destructuring: let {a, b} = e
A record pattern on the left of a binding extracts fields by name. It is irrefutable — it can never fail at runtime, so it is allowed exactly where no branching is possible:
w = let { upper, lower } = bollinger(close, 20) in upper - lower // level — `let … in` is an EXPRESSION
{ macd, hist } = macd(close) // a program-level bind destructures too- a named field that does not exist, or a non-record scrutinee, is
[ErrField]; - if the scrutinee is
na, every bound field isna— absence flows inward, consistently with the comparison rules; - variant destructuring is refutable and therefore lives in
match, whose exhaustiveness the checker enforces ([ErrTotalMatch]) — aletnever branches.
The ? family: ??, ?., ternary
Three pieces of pure sugar, all desugared to sealed constructs — no new kinds, no new semantics:
| sugar | desugars to | notes |
|---|---|---|
x ?? d |
nz(x, d) |
kind = x ⊔ d; fills na with a default |
e?.f |
if is_na(e) then na else e.f |
safe navigation; chainable, short-circuits at the first na |
c ? a : b |
if c then a else b |
same tree; c must be a signal |
m = macd(close) // record{macd, signal, hist}
len = close[1] ?? close // price — first bar handled
hist = m?.hist ?? 0 // safe-nav then default
side = close > open ? 1 : -1 // ternary, right-associativeBecause ?? is nz, it follows the join rules: branches of different dimensions widen
toward quantity (with a warning), and mixing representation tags (f64 vs decimal) is
[ErrRepr] — the sugar cannot do what the desugared form would not.
The _ placeholder
At the argument site of a higher-order kernel, a single free _ denotes the one parameter
of an implicit lambda:
scaled = window(close, 20).map(_ * 1.1) // ≡ .map((x) -> x * 1.1)
present = vec.where(window(close, 20), (x) -> is_some(x)) // a namespaced HOF — write the lambda
window(close, 20).fold(0, _ + _) // ✗ — two-parameter position: write (acc, x) -> acc + xThe placeholder is confined to a prelude kernel reached by UFCS, where the receiver
fixes the one parameter; a namespaced higher-order call such as vec.where(…) takes an
explicit lambda, because _ has no binder to attach to there.
The rule is strictly mono-argument: exactly one _, in a position expecting a
one-parameter function. Two or more _, or a two-parameter site like fold, are rejected
with a request for an explicit lambda — the sugar never guesses which _ is which. The
pattern _ of match arms is a different, position-distinct symbol; the two never collide.
Precedence and associativity
From loosest to tightest. The normative stratified grammar — including why this table needs no precedence annotations at all — is in grammar.
| tier | operators | associativity | notes |
|---|---|---|---|
| 1 | -> |
right | one arrow token; its role (lambda, tween pair, on, match arm, for) is decided by its head position — see grammar |
| 2 | c ? a : b · if…then…else · let…in |
right | else is mandatory — no dangling-if |
| 3 | ?? |
right | null-coalescing |
| 4 | or |
left | |
| 5 | and |
left | |
| 6 | not |
prefix | |
| 7 | < > <= >= == != cross_up cross_down |
non-associative | a < b < c is a parse error — write a < b and b < c |
| 8 | + - |
left | |
| 9 | * / % |
left | |
| 10 | unary - |
prefix | tighter than *: -a * b = (-a) * b |
| 11 | postfix: f(…) · [n] · @c · .m · ?.m · with {…} |
left | one tier, applied in lexical order: f(x)[1]@"1d".m = (((f(x))[1]) @ "1d").m |
| 12 | primary | — | literals, names, (…), […] list, {…} record/block, match, scene |
Outside the cascade:
..— non-associative, legal only in range positions (fill a..b,input(n, lo..hi));@— its right operand is a restricted clock operand, sox @ "1d" + 1is(x @ "1d") + 1;:and,— separators, never operators.
Each tier refers only to the next tighter one — no cross-recursion — which is what makes the whole cascade unambiguous by construction rather than by side condition.
See also
- Kinds — the lattice these operators compute over: sorts,
⊤/⊥, coercion edges, tags. - Inference — how kinds are assigned, presentation inference, and the full error catalogue.
- Grammar — the normative grammar, the single arrow’s five readings, disambiguation.
- Time & state — streams, clocks,
scan/fold/loop, causality and warm-up in depth. - FDK — Asset & currency — the complete asset-tag model: venues,
toSource, money. - FDK — Compute —
math.*/stat.*and the dimensional rules of the numeric library.