Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Units of measure

Status: Partly implemented. This page proposes compile-time dimensional analysis for Plenty: quantities whose physical dimension is part of the type, checked by the compiler and erased before code generation. Milestone 1, the language, shipped through issue #701 (local PRs #885, #887–#891); see dimensions, quantities and where the implementation differs. Milestone 2’s std.units shipped, including wrap_list and strip_list. Milestone 3’s shared instance bodies shipped; plot axis labels remain under B97. Companion pages record the precedents and the spike evidence. Researched against local main at bd407f05 on 2026-11-03; reconciled with eb772202.

Goal

A program that adds a length to a time, or passes an acceleration where a velocity is expected, should fail to compile. A program that passes these checks should run exactly as fast as the same program written with f64, and allocate nothing more.

This example needs std.units (milestone 2). Quantity is built in and needs no import.

import std.units as u
from std.units import Length, Time, Velocity

def speed(d: Quantity[f64, Length], t: Quantity[f64, Time]) -> Quantity[f64, Velocity]:
    d / t

def main() -> Result[(), IoError]:
    d = 3.2 * u.km
    t = 41.0 * u.minute
    v = speed(d, t)
    print(v, v.to(u.km / u.hour))?      # 1.3008130081300813 m/s 4.682926829268292
    print(d + t)?                       # compile-time error: Length + Time
    Ok(())

Recommendation

  1. Dimensions are a compiler-known kind of type. A module declares base dimensions (dimension Length("m")) and names derived ones (dimension Velocity = Length / Time). The compiler normalizes every dimension expression to a product of base dimensions with integer exponents, so Length / Time * Time is Length.
  2. Quantity[T, D] is a built-in numeric wrapper, like NonZero[T]. It has T’s representation. The compiler defines its operators: +, - and comparisons need equal dimensions; * and / combine them.
  3. The type carries the dimension, not the unit. A value is stored in the coherent SI unit of its dimension (metres, seconds, kilograms). km, minute and pound are constants, and q.to(unit) reads a value back in any unit. This is the uom and GNAT model.
  4. Storage types compose. T may be any number type, including Positive[i64], NonNegative[i64] and NonZero[i64]. An operation’s storage result is whatever T’s own operator returns; its dimension comes from the dimension algebra. Units never drop out of a result.
  5. std.units declares SI in Plenty: the seven base dimensions, named derived dimensions, prefixed and common units, CODATA constants, and dimension-aware sqrt, hypot and friends.

The recommendation needs no value generics, no type-level integers and no generic operator hooks, which Plenty lacks; see what the language could do before.

What the language could do before

The spikes ran on the release compiler built from main on 2026-11-02, before issue #701. Their library workarounds still behave as shown; the built-in Quantity[T, D] now covers the rows marked No and Partly.

NeedTodayEvidence
Distinct types per dimensionPhantom type parameters: Q[f64, Length] and Q[f64, Time] are different typesSpike 1
Same-dimension +, -, <@newtype forwarding over a phantom-tagged class; mixing dimensions is rejectedSpikes 1, 2
Code generic over one dimensiondef total[T, D](xs: &list[Q[T, D]]) -> Q[T, D]Spike 7
Zero costOne-field classes are represented as their field; operator hooks on a one-f64 class compile to the same instructions as a.x + b.x (operator lowering)Reference
Length * Length -> Area, genericallyNo. Operator hooks cannot have type parameters, a class has one __mul__ signature, and there are no associated types or type-level arithmeticSpike 4
A domain inside a quantityPartly. @newtype forwards Positive + Positive as Positive, but Positive - Positive returns a bare i64: the unit is lostSpike 3
Positive[f64]No. Integer domains take only signed integersSpike 5

So a phantom-tag library can check additive code today, but multiplication and division, the core of dimensional analysis, need a language change. Hand-written classes per dimension pair (spike 6) work for a handful of quantities and do not scale: each class can declare only one __mul__.

Options

OptionBenefitCost
A. Phantom-tag library with @newtype (works today)No language changeAdditive checks only; products need a hand-written function per pair
B. std library over value generics: Quantity[T, const L: i8, const M: i8, const T: i8, ...]Reuses B83Needs B83, its deferred derived-size expressions (L + L2), generic operator hooks with value parameters, and a second __mul__ for scalars. Seven fixed base dimensions, no user dimensions. Types print as Quantity[f64, 1, 0, -1, 0, 0, 0, 0]; a generic function over one dimension takes seven parameters
C. Compiler-known dimensions and built-in Quantity (recommended)Readable types and diagnostics; user dimensions; one parameter per generic dimension; no new operator-hook machineryA new kind of type and dimension expressions in the checker
D. C plus F#-style inference through dimension expressions in parameter typesdef sqrt[D](x: Quantity[T, D * D]) -> Quantity[T, D] infers DUnification over abelian groups in the checker; later, if code needs it
E. Runtime units (Pint, astropy, Measurement in Swift)No type-system workRun-time checks and storage per value; errors appear only on executed paths
F. Units in the type (mp-units, Unitful): km and m are distinct typesExact integer storage in any unitConversion and promotion rules between units of one dimension; many more types

Rust is a useful warning about option B. uom encodes exponents with typenum type-level integers because stable const generics cannot compute L1 + L2 in a signature; that needs the unstable generic_const_exprs. The const-generic ports (tiny-uom, const_unit_poc) require nightly features and stopped in 2020. Option B in Plenty would hit the same wall at the derived sizes that B83 deliberately defers.

C++ took the library route too: Boost.Units with template metaprogramming, then mp-units with C++20 class-type template parameters, now proposed for C++29 as P3045. Both are large libraries and rely on language features Plenty does not have and should not add for this. Ada GNAT and F# build dimensions into the compiler, as option C does. See the precedents.

Design

Declaring dimensions

dimension Length("m")
dimension Mass("kg")
dimension Time("s")
dimension Velocity = Length / Time
dimension Force = Mass * Length / Pow[Time, 2]
  • A base dimension has a name and the symbol of its coherent unit. Its identity is nominal, like a class: pkg.Length and std.units.Length differ.
  • A derived dimension is a transparent alias for a dimension expression, like a type alias. Velocity and Length / Time are the same type.
  • One is the built-in dimensionless dimension, the empty product.
  • dimension declarations are module-level and follow ordinary visibility (pub dimension Length("m")). dimension becomes a keyword; it is a declaration form, so this is the one source change existing programs could meet.

A dimension is a type with no values. It may appear only as a dimension argument, so Quantity[f64, Length] is valid and x: Length is not.

Dimension expressions

Wherever a dimension is expected, the compiler accepts:

FormMeaning
Length, Velocity, DA base dimension, alias or dimension parameter
A * B, A / BProduct and quotient, left-associative, with parentheses
Pow[A, n]Integer power; n is an integer literal, possibly negative
Root[A, n]nth root; every exponent of A must be divisible by n; n is nonzero, and a negative n takes the reciprocal
OneDimensionless

The normal form is a list of (base dimension, nonzero i8 exponent) pairs sorted by base identity. Two dimensions are equal exactly when their normal forms are equal. An exponent outside i8 is a compile-time error, including in an intermediate result that a later factor would cancel.

Exponents are integers. Root rejects a dimension it cannot divide exactly: the square root of Pow[Length, 3] is an error, not Length^(3/2). This excludes units such as V/√Hz from noise spectral density. F# added rational exponents in its fourth version for these cases; the normal form could store rationals later without changing any spelling.

Generic dimensions

D: Dimension declares a dimension parameter. Dimension is a built-in constraint, like IntType; it admits exactly the dimension types.

def mean[T: FloatType, D: Dimension](xs: &list[Quantity[T, D]]) -> Quantity[T, D]
def kinetic[M: Dimension, V: Dimension](m: Quantity[f64, M], v: Quantity[f64, V]) -> Quantity[f64, M * V * V]
def sqrt[T: FloatType, D: Dimension](x: Quantity[T, D]) -> Result[Quantity[T, Root[D, 2]], MathError]

One rule keeps inference structural, as it is today: in parameter types, a dimension argument is either concrete or a bare parameter. Return types, annotations and bodies may use any expression. Since Plenty checks bodies per instance, M * V * V and Root[D, 2] are evaluated after substitution, where every operand is known. This is the form B83 calls “expressions in non-inferred output positions”, applied to dimensions instead of integers.

A Root that does not divide is reported as an instance error at the call that required it, through the existing instance diagnostics:

main.plenty:8:9: cannot instantiate `units.sqrt[f64, Pow[Length, 3]]`: `Root[Pow[Length, 3], 2]` is not a whole dimension
  note: `Length` has exponent 3, which 2 does not divide

Option D, inferring D from x: Quantity[T, D * D], stays out until code needs it. Option C programs remain valid under it.

Quantities

Quantity[T, D] holds one T measured in the coherent unit of D. T is a number type: an integer, a float, or an integer domain type. D is a dimension.

OperationResult
Quantity[f64, Length](3.0)3 m. The constructor takes the coherent-unit value; prefer 3.0 * u.metre in application code.
q.get()The stored T, in coherent units
q.to(unit)(q / unit).get(), for float storage: unit is a quantity of the same dimension, such as u.km. Mismatched dimensions are a compile-time error.
a + b, a - bBoth Quantity[T, D]; result Quantity[R, D], where R is the type T + T or T - T returns
a * b, a / bQuantity[R, A * B] and Quantity[R, A / B]; / needs float storage
k * q, q * k, q / kA scalar T scales without changing the dimension
k / qQuantity[R, One / D]
a // b, a % bInteger storage: // divides dimensions, % keeps a’s and needs equal dimensions
-q, +qAs for T, dimension unchanged
<, <=, ==, …Equal dimensions; bool
+=, -=Equal dimensions. *= and /= take a scalar T only, since the dimension of a variable cannot change.
print(q), f"{q}"The value, then the coherent unit: 1.3 m/s, 9.81 m/s^2. A format spec applies to the number: f"{q:.2f}" is 1.30 m/s.
debugQuantity[f64, Length * Pow[Time, -1]](1.3), the normal form

Rules with no exceptions:

  • Scalars only scale. A plain T, including a literal, may multiply or divide a quantity, never be added to it or compared with it. q > 0.0 is an error; write q > 0.0 * u.metre. F#’s 0.0<_> wildcard is the exception this rule avoids.
  • Dimensionless is still a quantity. d / d is Quantity[f64, One]. .get() returns the f64. There is no implicit conversion.
  • Storage types never mix. Quantity[f32, D] + Quantity[f64, D] is an error, as f32 + f64 is.

Overflow, division by zero and checked: behave as they do for T. A quantity operation is T’s operation, so a checked: block reports OverflowError[T] or ZeroDivisionError.

Unit constants and conversions

A module constant may have a quantity type, with an initializer built from literals and other quantity constants:

pub const metre: Quantity[f64, Length] = Quantity[f64, Length](1.0)
pub const km: Quantity[f64, Length] = 1000.0 * metre
pub const hour: Quantity[f64, Time] = 3600.0 * second
pub const newton: Quantity[f64, Force] = kg * metre / (second * second)

Construction multiplies by the unit, and to divides by it. Both are one floating-point operation, folded at compile time when the operands are constants. Converting through the coherent unit can round: 0.1 * u.foot then .to(u.foot) need not return exactly 0.1. Programs that need exact values in a non-coherent unit should declare their own dimension or keep that unit’s integer count, as std.time.Duration keeps nanoseconds.

Unit constants are f64. f32 quantities convert with Quantity[f32, D].from(q), which narrows the stored value as f32(x) does; Quantity[f64, D].from(q) widens.

Domain storage

The user-facing question was whether Positive, NonNegative and NonZero can carry units. They can, by nesting the domain inside the quantity and taking the storage result from the domain’s own rules:

Expression, with p: Quantity[Positive[i64], Length]Result
p + p, p * pQuantity[Positive[i64], Length], Quantity[Positive[i64], Pow[Length, 2]]
p - p, -pQuantity[i64, Length]: the domain drops, the unit stays
n // p, with n: Quantity[i64, Area]Quantity[i64, Length], without the zero or MIN // -1 check
Quantity[Positive[i64], Length](0)Compile-time error, as for Positive[i64]
Quantity[Positive[i64], Length].try_from(k)Result[..., RangeError] for a computed k
Positive[Quantity[i64, Length]]Rejected: one nesting order, the domain inside

Today’s @newtype forwarding does the opposite for -: it returns the bare i64 (spike 3). The built-in rule rewraps every result.

Integer storage counts whole coherent units, so Quantity[i64, Length] holds whole metres. That suits counts such as pixels, bytes or items, declared as user dimensions, more than SI quantities.

Float domains do not exist. Mass, absolute temperature and the argument of a logarithm would benefit from a Positive[f64]. Its rules differ from the integer domain: it must exclude NaN, and * cannot preserve it because a product of two small positive values can underflow to zero. That belongs in a separate proposal. The storage rule above means a later float domain works inside quantities with no further units design.

std.units

Written in Plenty, as declarations and small functions:

PartContents
Base dimensionsLength (m), Mass (kg), Time (s), Current (A), Temperature (K), Amount (mol), Luminosity (cd)
Derived dimensionsArea, Volume, Velocity, Acceleration, Force, Pressure, Energy, Power, Charge, Voltage, Resistance, Frequency, Density, and the other named SI derived quantities
UnitsSI prefixes on the base and named derived units (km, ms, kN, MPa, kWh), plus minute, hour, day, litre, tonne, bar, atm, eV, and the international foot, inch, mile, pound and pound-force
ConstantsThe exact SI defining constants (c, h, e, k_B, N_A) and CODATA values such as G, typed
Functionssqrt, cbrt, abs, hypot, min, max, clamp, mean, sum; exp, log and trigonometry take Quantity[f64, One]
Temperaturefrom_celsius, to_celsius, from_fahrenheit, to_fahrenheit (affine; see out of scope)
Timefrom_duration(&Duration) -> Quantity[f64, Time] and a fallible to_duration
Listswrap_list[D](xs: list[T]) -> list[Quantity[T, D]] and strip_list, consuming the list without copying, since the element layouts are identical

Angles are dimensionless, as in SI: u.degree is Quantity[f64, One] holding π/180. A program that wants angles checked declares dimension Angle("rad") and converts at trigonometric calls.

Working with the numeric libraries

LibraryBoundary
std.dataframeunits.wrap_list[Length](frame.values_f64("x")?) gives typed values with no copy beyond values_f64
std.plotColumn.Num(units.strip_list(qs)), with a hand-written label for now. Labels from units are open design.
std.numeric (proposal)Solvers keep f64 callbacks. A callback strips and rewraps at its boundary. Quantity-generic solvers wait for evidence.
std.linalgMatrix stays f64. Elements of one matrix often have different dimensions, which a single D cannot express; out of scope

Plot axis labels

Open design, deferred from milestone 3 (B97). std cannot yet give a unit symbol as text, and a unit constant is only a value: u.km is Quantity[f64, Length] holding 1000.0, with no name. Candidate pieces:

  • symbol() on Quantity[T, D]. Returns the coherent-unit symbol that printing uses ("m/s") as a static string, without allocating; "" for a dimensionless quantity. This also serves hand-written labels: f"speed ({Quantity[f64, Velocity].symbol()})".
  • A quantity column in std.plot. For example plot.quantity("speed", &xs) stores the values through strip_list, with no copy, and records the dimension. With no explicit label, the axis title is speed (m/s). An explicit label wins.
  • A display unit that fits the data. Values of 2.5e6 m read better as 2500 km. The column would pick a unit whose scaled values are easy to read, for example so the largest magnitude lies in [1, 1000), and label the axis with that unit. Questions:
    • Where candidate units come from. Unit constants carry no names, so this needs a per-dimension table of (symbol, factor) pairs, from SI prefixes on base symbols (mm, m, km), from named units in std.units, or both. Named-unit display (kN, J) is related later work.
    • Prefixes on compound symbols: km/s or kN, and which factor of a product takes the prefix.
    • Scaling without a copy: divide at tick and label time rather than rewriting the column.
    • Overrides: the caller fixes the display unit and its symbol, for example plot.quantity_in("distance", &xs, u.km, "km"), since the constant has no name to show.
    • Shared axes: series on one axis must share one display unit.

Cost

  • Run time: none. Quantity[T, D] has T’s representation, operators lower to T’s instructions, and unit constants fold. Only print and formatting read the dimension, as a static symbol string.
  • Code size. Generic instances that differ only in dimension arguments have identical bodies unless they format a quantity. The implementation should emit one body for them. Rust and C++ rely on the linker’s identical code folding for this; Plenty’s per-instance checking makes it an explicit step after checking.
  • Compile time. A normal form is a short sorted list, interned with other types. Each dimension expression is normalized once per instance.

Implementation

Milestone 1 shipped as designed, with these differences. The reference pages are authoritative.

TopicThis designImplemented
Integer divisiona // b where T supports it/ is float-only and // integer-only for quantities; integer storage has no to, so use // and .get()
Root degreePositive nNonzero n; negative takes the reciprocal
Pow, RootReserved formsFallback names that a local declaration or import shadows
Exponentsi8 normal formLiteral n within i64; every intermediate exponent must fit i8
Debug outputQuantity[f64, Length / Time](1.3)Normal form: Quantity[f64, Length * Pow[Time, -1]](1.3); alias notes appear only in diagnostics
checked:OverflowError[T]Also ZeroDivisionError, as for the underlying arithmetic
ConstantsLiterals and quantity constantsAlso casts of extracted values, .get() and float .to; no user function calls
Zero-cost evidenceRelease instructions match f64Matched for f32 and f64 +, * and .to on x86-64; not measured on other targets
Instance sharingMilestone 1 if cheapMilestone 3, for every function: identical placed code is folded into one body. Instances that format a value or build a collection keep separate bodies, since their type descriptors name the dimension; functions used as values keep their own addresses
std.units listswrap_list[D](xs: list[T]) and strip_listf64 storage only, like the other std.units functions. A std-only compiler intrinsic, __list_retag, consumes the list and stores the target list type’s descriptor in its header; it accepts only a quantity and its own storage type as element types
std.units storageUnstatedUnits, constants and the std.math-based functions use f64; abs and clamp accept any storage
min, maxIn std.unitsNot repeated: the generic std.math.min and max already accept quantities
meanUnstatedReturns Option, Nothing for an empty list, as std.math.min_all does
Symbol orderSI order, kg*m/s^2Declaration order within a module, modules by name; std.units declares SI order

Out of scope

FeatureReason
Units in the type (option F)Conversion and promotion rules for one dimension; the dimension-only model covers the checks
Affine quantities (°C, °F, positions, points in time)from_celsius converts at the boundary. mp-units models these as quantity_point; add one only with use cases. std.time already separates Instant from Duration.
Quantity kinds: torque vs energy, Hz vs Bq, rad vs srSI gives them one dimension. A user dimension separates them where it matters.
Rational exponentsInteger exponents first; see dimension expressions
Logarithmic units (dB, neper)Not multiplicative; ordinary functions
Matrices and dataframes with per-column unitsNeeds a dimension per column or element, as UnitfulTensors.jl does; later
Dimension expressions in parameter types (option D)Inference by abelian unification; later if needed

Milestones

  1. Language. Delivered (issue #701). dimension declarations, the Dimension constraint, dimension expressions with Pow and Root, normal forms and identity, the parameter-type rule, built-in Quantity[T, D] for every number and integer domain storage type, operators, get, to, try_from, printing, quantity module constants and diagnostics. Reference page, tutorial lesson and cheat-sheet entry.
  2. std.units. Delivered. SI dimensions, units, constants and functions; temperature and Duration conversions; zero-copy list conversions.
  3. Measured follow-ups. Shared bodies for instances differing only in dimension (delivered); plot axis labels from units (deferred, design open); then any later item with a use case.

Testing and acceptance

  • Algebra: normalization is commutative and associative, cancels exactly, and Root rejects every non-divisible exponent. Property tests over random expressions compare normal forms against a reference evaluator.
  • Operators: every operator for every storage type, including each domain and checked: blocks, with expected storage and dimension results.
  • Rejections: each rule above has a diagnostic test, including additive scalars, mixed storage, Positive[Quantity[...]], dimensions in value positions and expressions in parameter types.
  • Zero cost: the operator-lowering probe extended to quantities: a + b, a * b and q.to(u.km) on Quantity[f64, D] must emit the same release instructions as the f64 program. Allocation-denial tests cover quantity arithmetic and wrap_list.
  • Library: std.units constants checked against the SI Brochure (9th edition) and CODATA 2022; conversions round-trip within one ulp where the factor is exact.

Risks and open questions

  • Diagnostics through aliases. Resolved: diagnostics show the normal form with a note naming the first visible matching alias in declaration or import order, using the importing module’s spelling.
  • Symbol spelling. Resolved: ASCII base symbols in declaration order within a module, with modules ordered by name. std.units declares SI order, so a newton prints kg*m/s^2. Debug spellings keep the normal form, sorted by qualified base name. Named-unit display (J) is later work.
  • The dimension keyword may conflict with existing identifiers outside the repository. Pow and Root are not keywords: a local declaration or import with either name keeps its ordinary meaning.
  • Learner cost. Units add a concept. The tutorial lesson should come after generics and teach the rules above without the algebra’s internals.