◆ Flux

units — general quantities

The units pillar is a sealed, governed amendment to the kind system; its rollout follows the v1 core.

The units pillar carries physical and general-purpose quantities — length, mass, volume, data size, temperature, screen space, and the rates built from them — in the type system, so the combinations that mean nothing fail to compile. This page catalogues the meas[u] tag, its dimensional algebra, the affine point/delta bit, and the rules for getting a raw number in and out. The annotation substrate it reuses — a tag axis on num, the affine point/vector distinction — is rule A14, stated normatively in Kinds; what follows is its unit-specific surface.

A metre is not a second. A kilogram is not a kilobyte. Twenty degrees Celsius plus twenty degrees Celsius is not forty degrees Celsius — and a language that lets you write it is a language that will eventually produce a number you cannot defend. The pillar uses the same machinery the market kinds already use: a tag on num, exact conversions, and rules that make the meaningless cases fail to compile.

New here? Start with Guide §5 — Kinds →

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.

Three regimes, one rule each

Real units fall into exactly three classes, and each gets its own treatment. This is the settled consensus across the systems that have tried, and it is worth stating up front because most half-measures come from conflating them:

Regime Examples Treatment
Linear length, mass, volume, data size, speeds and rates the tag, plus exact multiplicative conversion
Affine temperature (°C, °F, K) the same tag, plus a point | delta bit that selects the right formula and outlaws the meaningless arithmetic
Nonlinear decibels, pH, magnitudes not units. Explicit pure functions — a unit conversion never leaves its convexity class

Nothing is removed by that third row: decibels still work. They work as a function, because that is what they are.

The tag

meas[m]         meas[m·s⁻¹]        meas[kg·m·s⁻²]        meas[B]        meas[px]

A unit is a product of symbols with integer exponents, drawn from a closed, versioned catalogue. Each symbol declares its family (length, mass, data, temperature…), its exact factor to that family’s canonical unit — a pinned rational, so km = 1000 m and KiB = 1024 B and mi = 1609344/1000 m are exact, not approximate — and, for temperature only, its affine slope and offset.

Structurally this is the same move the currency-pair annotation already made: an annotation axis carried by num, whose top is the bare num. Zero new sorts, zero new lattice height.

FLUX
d       = unit.km(5)        // meas[km]
elapsed = 7200s             // duration — two hours. NOT a unit: see below
v       = d / elapsed       // meas[km·s⁻¹] — a distance ÷ a duration: the time bridge

Two constraints on the catalogue are worth knowing, because they are the ones that surprise people:

The algebra

± demands the identical unit. Different units, no conversion, no sum:

FLUX
side = unit.m(5) + unit.m(3)   // meas[m]  ✓
unit.m(5) + unit.ft(3)         // ✗ [ErrDim] — convert first: unit.m(5) + toUnit(unit.ft(3), m)

× and ÷ compose exponents and unify symbols within a family, folding the exact factor:

FLUX
r = unit.km(1) / unit.m(1)     // ratio — the same family cancels, ×1000 folded exactly
v = unit.m(6) / 2s             // meas[m·s⁻¹] — the TIME BRIDGE: a measure ÷ a duration

Full cancellation gives you a plain ratio, as it should.

The time bridge is how s enters a tag. There is no unit.s(…) to divide by — the second reaches the algebra only as the kind that owns it, duration. The bridge runs both ways: meas[u] ÷ duration → meas[u·s⁻¹] (distance ÷ time = speed) and meas[u·s⁻¹] × duration → meas[u] (speed × time = distance). Time components canonicalize to s, so an h or a min in a tag normalizes before it nets, and an expression whose tag nets to pure time returns a duration again — meas[m] ÷ meas[m·s⁻¹] is an ETA. That closed loop is what makes a stranded meas[s] unreachable rather than merely discouraged.

The time bridge — a closed loop between duration and the unit algebra Figure — the time bridge as a closed loop: a duration enters the unit algebra only through the two doors (meas[u] ÷ duration → meas[u·s⁻¹], meas[u·s⁻¹] × duration → meas[u]), and a tag that nets to pure time returns a duration — so a stranded meas[s] is unreachable.

The mixed wall. A market dimension and a physical unit do not multiply:

FLUX
close * unit.kg(2)          // ✗ [ErrDim] — a price is not a mass, and their product means nothing

Finance keeps its own axes — currency through the asset tag, calendar time through period, angles through their own unit — and the wall between them is an enumerated edge, not an accident of the rules.

Affine scales: the point / delta bit

This is the part every units library gets wrong at least once. A temperature can be a point on a scale (it is 20 °C outside) or a difference on that scale (the temperature rose by 5 °C). They convert differently, and only one of them can be added:

FLUX
t  = unit.tempC(20)          // meas[°C · point] — a POINT: 20 degrees Celsius
dt = unit.tempCDelta(5)      // meas[°C · delta] — a DIFFERENCE of 5 degrees

warm = t + dt                // meas[°C·point]  ✓  point + delta = point   (25 °C)
rise = t - t                 // meas[°C·delta]  ✓  point − point = delta
t + t                        // ✗ [ErrDim] — "20 °C plus 20 °C" is not 40 °C. It is nothing.
hot  = toUnit(t,  F)         // 68 °F   — the affine formula: slope AND offset
dHot = toUnit(dt, F)         // 9 °F    — the linear formula: slope ONLY

If that distinction looks familiar, it should: it is exactly the point/vector distinction the price axis already makes (price − price = level). The units pillar does not invent a mechanism — it reuses the one the language was built on.

The bit also disciplines the functions that read a scale’s arbitrary zero:

Operation On a point On a delta or a linear unit
abs, sign ✗ [ErrDim] — they read the zero, which is arbitrary ✓
sum over a collection ✗ [ErrDim] ✓
a rate of change ✗ [ErrDim] ✓
floor, ceil, round ✓ (quantization within the scale) ✓

Why abs(20 °C) is refused. The absolute value of a temperature is not a temperature — it is a statement about the distance from a zero that somebody chose in 1742. On the Kelvin scale the same expression would give a different answer, and both would be “correct”. The compiler refuses to pick one, and offers you the conversion that makes your intent explicit.

Getting values in and out

In: a declared meta-head on an input, checked against the catalogue.

Out: meas.value(x) strips the tag when you genuinely want the bare number, toUnit(x, u) converts, and meas.valueIn(x, u) does both in one call. There is no implicit coercion, ever — meas[u] ≤ num is a lossy edge, so it warns and offers a quick-fix rather than silently discarding the unit that was the whole point.

An affine point is excluded from that lossy tier altogether. A bare-num site is scale-ambiguous for a point — 20 °C and 68 °F are the same temperature and different numbers — so meas[°C·point] ≤ num is a hard [ErrDim], not a warning, and a bare meas.value on a point is [ErrArg]. The quick-fix is valueIn, whose signature forces you to name the scale at the exit: meas.valueIn(t, F) is 68, and it says so in the call. The lossy half of the rule is the convenience; this half is the one that catches the bug — it is the same arbitrary-zero argument that refuses abs(20 °C), applied at the boundary.

Formatting is locale-aware and goes through the pinned tables, so a measurement renders correctly without the number becoming locale-dependent — see i18n.

What this costs and what it buys

It costs a tag on num, one bit for affine scales, and a closed catalogue. It buys:

FLUX
distance = unit.km(5)                // meas[km]
bytes    = unit.B(2048)              // meas[B]
elapsed  = 7200s                     // duration — two hours
speed  = distance / elapsed          // meas[km·s⁻¹] — a distance ÷ a duration, and the compiler knows it
budget = bytes / elapsed             // meas[B·s⁻¹]  — a bandwidth, correctly
wrong  = distance + elapsed          // ✗ [ErrDim] — caught here, not in production

And it composes with everything else: a column of measurements in a table, a measurement in a Model field, a measurement rendered by a chart — all of them carry the unit, and all of them refuse the same nonsense.

See also