A small, self-contained units-of-measure library for Go: a Unit is a
symbol, a Kind and a conversion factor; a Value pairs a magnitude with the
unit it is expressed in.
import "github.com/lestrrat-3d/units"
w := units.Millimeters(100)
in, err := w.In(units.Inch) // 3.937...
fmt.Println(w) // "100 mm"
_, err = w.Convert(units.Degree) // error: a length is not an angle
a, err := units.Millimeters(2).Mul(units.Millimeters(3)) // "6 mm^2", an Area
v, err := a.Mul(units.Millimeters(4)) // "24 mm^3", a VolumeA magnitude stripped of its unit is a bug waiting to happen. A bare float64 is
how 2 silently becomes 2 radians when 2 degrees was meant — the classic
CAD-API trap, where every number crossing the boundary is in some ambient
internal unit the caller has to remember.
Anything crossing an API boundary should be a Value: a number and its
unit, together, checked. Kinds never coerce — asking for a length as an angle is
an error, not a silent reinterpretation.
The package holds quantities and the conversions between them, and nothing else. It carries no document state, knows nothing of any application layered on top of it, and depends only on the standard library.
-
Units are typed, never stringly. You name a unit through a
Unitconstant (Millimeter,Inch,Degree), not by passing"mm"around.Lookupexists for deserialization; it is not the normal way to build a value. -
Kinds are dimensions, not an enumeration. A
Kindis a vector of exponents over length, mass and angle, so kinds compose:Muladds them andDivsubtracts them, andArea,Volume,Density(M·L⁻³),MomentOfInertia(M·L²) andSecondMomentOfArea(L⁴) fall out for free. A kind nobody named — an inverse length, say — is still a kind: it compares and prints (L⁻¹), though no base unit is registered for it. -
An angle is its own dimension, even though a radian is physically a ratio of two lengths, so a bare number can never pass as an angle. The one carve-out is that
Add/Subaccept an angle and a dimensionless value together. -
An exponent that overflows says so, and keeps saying so. Exponents are
int8; a composition that runs off the end saturates and marks the kind overflowed (Kind.Overflowed()). The mark is sticky:Mul,DivandPowpropagate it, so an overflowed kind never equals a named kind, prints asoverflowed, has no base unit, and carries the reserved[overflow]symbol. A saturated exponent is a lie about the number; without the mark, dividing an overflowedL¹²⁷byL¹²⁶would hand back a perfectly plausibleLength. -
Every named kind has a base unit: the millimetre (
Length), the square millimetre (Area), the cubic millimetre (Volume), the kilogram (Mass), the kilogram per cubic millimetre (Density), the kilogram square millimetre (MomentOfInertia), the quartic millimetre (SecondMomentOfArea) and the radian (Angle). Every unit stores its factor to its kind's base. Symbols are printable ASCII without the space, with a caret for an exponent:mm^2,in^3,kg/m^3. A unit whose conventional symbol is not ASCII (µm, °, Å) registers under an ASCII spelling (um,deg,angstrom), as the built-ins do. -
A value of an unnamed kind is transient. It carries a synthetic, unregistered unit whose symbol is bracketed (
[L^-1]) —Lookupwill not resolve it, so it must not be persisted. Compose it back into a named kind first. It is still that kind everywhere else: aSystem's default unit for it measures it, so presenting a value never changes what it measures. ASystemfield left unset, or holding a unit of the wrong kind, is ignored in favour of the kind's base unit — the zeroSystempresents every kind as itself. -
A result is finite, or it is an error.
Add,Sub,Mul,Div,InandConvertnever hand back an+Infor aNaNwith a nil error: a zero divisor isErrDivideByZero, and an overflowing or NaN result isErrNotFinite. The operations that have no error to return —New,FromBase,Scale,Neg— cannot check, and do not. -
It is the result that must be finite, never an intermediate. A base magnitude (
Value.Base(), the magnitude in the kind's base unit) overflows for ordinary values —Meters(1e307)is1e310 mm— and underflows for others —Grams(1e-322)is1e-325 kg. No operation forms one. SoMeters(1e307)converts to metres, divides by itself to1, multiplies byMillimeters(1e-300)to1e10 mm², and equals itself, andGrams(1e-322)is an ordinary divisor rather than a zero one.Base()is an accessor and reports that infinity — or that zero — honestly;System.In, which answers in the system's unit for the kind, returns the infinity rather than a finite number in another unit. -
The range costs no accuracy. Conversions are exact where the arithmetic is:
25.4 mmis exactly1 in,1000 g/cm³exactly1e6 kg/m³, a value in its own unit is its own magnitude, and a value divided by itself is exactly1. The arithmetic rounds where the plain expression rounds and never once more — subnormal results included, where a second rounding would be worse than the plain expression:Scalar(1.25).Div(Centimeters(1e307))is1.25e-308. -
A quantity serializes with its unit.
Valueimplementsencoding.TextMarshaler/TextUnmarshaler, soencoding/json— and any other text-based encoder — writes it as"<magnitude> <symbol>":"10 mm","7850 kg/m^3","90 deg". A dimensionless value is the bare number ("1.5"), sinceOne's symbol is empty. The round trip is exact — the same unit, and the same magnitude bit for bit — and the symbol is resolved throughLookup: an unregistered symbol is an error, never a guess and never a silently dimensionless value. What cannot be read back is not written: a value of an unnamed kind, of an overflowed kind, or with a non-finite magnitude has no text form and is an error rather than{}with a nil error.type Step struct{ Distance units.Value `json:"distance"` } b, err := json.Marshal(Step{Distance: units.Millimeters(10)}) // {"distance":"10 mm"}
-
The zero
Valueis 0 ofOne, so aValuedeclared withvarbehaves as a plain 0 in every operation. -
Extensible.
Defineregisters a new unit against a kind; a symbol must be printable ASCII without the space and may not be redefined, symbols opening with[are reserved for the library, and the factor to the kind's base must be positive and finite.Define,LookupandBaseUnitare safe to call from multiple goroutines.
This project is source-available, and is licensed under the PolyForm Noncommercial License 1.0.0.
- Noncommercial use is free. Individuals, hobby and personal projects, research, education, nonprofits, and government may use, modify, and redistribute it at no cost, subject to the license terms.
- Commercial / business use requires a separate license. Any use by or for a business, or for commercial advantage, is not permitted under the noncommercial license. To obtain a commercial license, reach out on Bluesky at @lestrrat.bsky.social.
This repository does not accept external pull requests.