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.
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 bridgeTwo constraints on the catalogue are worth knowing, because they are the ones that surprise people:
- Time symbols exist only as rate components.
meas[m·s⁻¹]is legal; a standalonemeas[s]is not, and there is no constructor for one. A duration has exactly one home in Flux — thedurationkind — and the algebra cannot strand a second one. You obtain one as a duration literal (7200s,500ms) or as a difference of times (time - time[n]); you then reach the unit world through the time bridge below, never through a fabricated time constructor. - Derived symbols declare their family as a power of another. A litre is length³ with an
exact factor to the cubic metre; a hectare is length² with an exact factor to the square
metre. So
meas[L]andmeas[m³]are two tags of one convertible family, and×/÷unify them exactly askmandmunify.
The algebra
± demands the identical unit. Different units, no conversion, no sum:
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:
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 durationFull 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.
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:
close * unit.kg(2) // ✗ [ErrDim] — a price is not a mass, and their product means nothingFinance 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:
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 ONLYIf 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:
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 productionAnd 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
- Kinds — rule A14: the tag mechanism, and the affine substrate this reuses.
- Operators — the dimensional algebra
meas[u]plugs into. - asset & currency — the sibling tag axis, for instruments and money.
- compute — measurements in columns, and the
metric[id]sibling annotation. - i18n — formatting a quantity for a locale without making the number locale-dependent.