measurable 1.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +271 -53
  3. package/dist/dimensions/angle.js +13 -6
  4. package/dist/dimensions/area.d.ts +14 -0
  5. package/dist/dimensions/area.js +46 -0
  6. package/dist/dimensions/data.d.ts +10 -0
  7. package/dist/dimensions/data.js +39 -0
  8. package/dist/dimensions/energy.d.ts +14 -0
  9. package/dist/dimensions/energy.js +29 -0
  10. package/dist/dimensions/force.js +6 -6
  11. package/dist/dimensions/frequency.d.ts +7 -0
  12. package/dist/dimensions/frequency.js +11 -0
  13. package/dist/dimensions/illuminance.d.ts +9 -0
  14. package/dist/dimensions/illuminance.js +16 -0
  15. package/dist/dimensions/index.d.ts +9 -0
  16. package/dist/dimensions/index.js +9 -0
  17. package/dist/dimensions/length.js +7 -7
  18. package/dist/dimensions/luminance.d.ts +7 -0
  19. package/dist/dimensions/luminance.js +13 -0
  20. package/dist/dimensions/luminousIntensity.d.ts +9 -0
  21. package/dist/dimensions/luminousIntensity.js +16 -0
  22. package/dist/dimensions/mass.js +13 -9
  23. package/dist/dimensions/power.d.ts +9 -0
  24. package/dist/dimensions/power.js +13 -0
  25. package/dist/dimensions/pressure.d.ts +14 -0
  26. package/dist/dimensions/pressure.js +21 -0
  27. package/dist/dimensions/temperature.js +9 -3
  28. package/dist/dimensions/time.js +19 -7
  29. package/dist/dimensions/volume.js +57 -24
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.js +2 -1
  32. package/dist/lib/Dimension.d.ts +49 -14
  33. package/dist/lib/Dimension.js +58 -21
  34. package/dist/lib/MeasurementSystem.js +2 -2
  35. package/dist/lib/Quantity.d.ts +94 -10
  36. package/dist/lib/Quantity.js +92 -37
  37. package/dist/lib/Rational.d.ts +58 -0
  38. package/dist/lib/Rational.js +174 -0
  39. package/dist/lib/Unit.d.ts +52 -11
  40. package/dist/lib/Unit.js +41 -8
  41. package/dist/lib/prefixes.d.ts +2 -2
  42. package/dist/lib/prefixes.js +1 -1
  43. package/dist/systems/imperial.js +19 -1
  44. package/dist/systems/metric.js +25 -1
  45. package/dist/systems/usCustomary.js +19 -1
  46. package/dist/utils/definePrefixed.d.ts +28 -0
  47. package/dist/utils/definePrefixed.js +72 -0
  48. package/dist/utils/scaleOf.d.ts +3 -0
  49. package/dist/utils/scaleOf.js +6 -0
  50. package/package.json +13 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,149 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [3.0.0] - 2026-06-18
9
+
10
+ This release adds value **formatting**. Units now carry a `symbol` and `plural`,
11
+ and a `Quantity` can render itself with magnitude-aware pluralization and
12
+ locale-aware number formatting. Threading symbol/plural through the unit builders
13
+ changes their signatures, hence the major bump.
14
+
15
+ ### Breaking
16
+
17
+ - **Unit builder methods take a `UnitDef` object instead of an aliases array.**
18
+ `Dimension.base` / `unit` / `affine` / `custom` now accept
19
+ `{ symbol?, plural?, aliases? }` as their final argument rather than a bare
20
+ `string[]` of aliases. Migrate `length.unit("inch", 0.0254, ["in", "inches"])`
21
+ to `length.unit("inch", 0.0254, { symbol: "in", plural: "inches" })`; plain
22
+ aliases still work via the `aliases` field.
23
+ - **`definePrefixed` takes the reference `Unit`, not a descriptor object.**
24
+ `definePrefixed(length, { name: "meter", symbol: "m" })` becomes
25
+ `definePrefixed(length, meter)` — it reads the name, symbol, and exact scale
26
+ straight off the unit. The `PrefixReference` type is removed.
27
+ - **`Unit`'s constructor options gained `symbol` / `plural`.** Only affects code
28
+ that calls `new Unit(...)` directly; units built through a `Dimension` are
29
+ unaffected.
30
+
31
+ ### Added
32
+
33
+ - **`Unit.symbol` and `Unit.plural`** — optional, first-class descriptors. Both
34
+ are also registered as parse tokens, so `Quantity.parse` accepts e.g. `"5 g"`
35
+ and `"5 grams"`.
36
+ - **`Quantity.format(options?)`** — renders `"<magnitude> <label>"`. `unit`
37
+ selects the label: `"auto"` (default — singular `name` at ±1, otherwise
38
+ `plural`), `"name"`, `"plural"`, or `"symbol"`, each falling back to `name`
39
+ when unset. The magnitude is rendered with `toLocaleString`; pass `locale`
40
+ and/or `numberFormat` (`Intl.NumberFormatOptions`) for locale- and
41
+ precision-aware output.
42
+ - **`Quantity.formatParts(options?)`** — the same options as `format`, but
43
+ returns the rendered `{ magnitude, unit }` as separate strings for custom
44
+ assembly (e.g. JSX).
45
+ - **`definePrefixed` derives each variant's `symbol` and `plural`** from the
46
+ reference unit (e.g. `meter` → `kilometer` / `km`), keeping the prefixed scale
47
+ exact via rational arithmetic.
48
+ - New exported types: `UnitDef`, `FormatOptions`, `FormattedParts`,
49
+ `BaseUnitOptions`, `UnitConversionOptions`, and `UnitOptions`.
50
+
51
+ ### Changed
52
+
53
+ - Added `assert-never` as a (small) runtime dependency, used for exhaustiveness
54
+ checking in formatting.
55
+
56
+ ## [2.0.0] - 2026-06-18
57
+
58
+ This release makes conversions and quantity arithmetic **exact**. Magnitudes and
59
+ unit transforms are represented as exact rationals internally and only collapse
60
+ to a `number` at the edge, so `foot → inch` is exactly `12` (not
61
+ `12.000000000000002`) and chains and round trips no longer accumulate drift.
62
+
63
+ ### Breaking
64
+
65
+ - **`Quantity.magnitude` is now a read-only getter** derived from the new
66
+ `Quantity.rational`, rather than a writable field. Assigning to
67
+ `quantity.magnitude` no longer works.
68
+ - **`Quantity` construction rejects non-finite magnitudes.**
69
+ `new Quantity(Infinity, unit)` (or `NaN`) now throws instead of being accepted.
70
+ - **`Unit.toBase` / `Unit.fromBase` are methods, not function-valued fields.**
71
+ Calling `unit.toBase(x)` is unchanged; holding an unbound reference such as
72
+ `const f = unit.toBase` no longer works.
73
+ - **`Unit`'s constructor options changed shape** to a discriminated union
74
+ (`{ linear }` vs `{ toBase, fromBase }`). Only affects code that calls
75
+ `new Unit(...)` directly; the `Dimension` builder methods are unaffected.
76
+ - **Conversion results changed value.** Outputs are now exact, so values that
77
+ previously carried floating-point error differ (e.g. `12.000000000000002` is
78
+ now `12`). `Quantity` equality and comparison are now exact rational
79
+ comparisons rather than float `===`, so quantities that are mathematically
80
+ equal compare equal even across a conversion that previously drifted.
81
+ - **Requires Node >= 14** (declared via `engines`); the library now uses `bigint`.
82
+
83
+ ### Added
84
+
85
+ - **`Rational`** — an exact rational number (`bigint` numerator/denominator),
86
+ exported from the package root. Supports `plus`/`minus`/`times`/`dividedBy`
87
+ (with `add`/`sub`/`mul`/`div` aliases), `negate`/`abs`, `equals`/`eq`,
88
+ `compare`, `sign`, `toNumber`, and the static `Rational.from`.
89
+ - **`Quantity.rational`** exposes the exact magnitude; the constructor and
90
+ `times`/`dividedBy` now accept `number | Rational`.
91
+ - **`Dimension.convertRational(value, from, to)`** — exact, rational-in /
92
+ rational-out conversion.
93
+ - **`Unit.linear`** — the exact `{ scale, offset }` transform for linear and
94
+ affine units (`undefined` for `custom` ones).
95
+ - **`Dimension.unit` and `Dimension.affine` accept `number | Rational`**, so a
96
+ ratio a decimal cannot represent exactly (e.g. Fahrenheit's `5/9`) can be
97
+ given exactly.
98
+ - New built-in dimensions: `area`, `data`, `energy`, `frequency`,
99
+ `illuminance`, `luminance`, `luminousIntensity`, `power`, and `pressure`
100
+ (each with its SI prefix ladder where applicable).
101
+ - `definePrefixed`'s reference `scale` is now optional, defaulting to `1`.
102
+
103
+ ### Fixed
104
+
105
+ - Linear and affine conversions are now exact (`foot → inch` is `12`, `1 L → mL`
106
+ round trips cleanly, and so on).
107
+ - SI-prefixed scales are computed in rational arithmetic, fixing drifted scales
108
+ such as `nanowattHour` (`3600 × 1e-9`).
109
+ - Built-in Fahrenheit is defined with exact rationals and round-trips without
110
+ drift in both directions.
111
+ - `Rational.toNumber` stays correctly rounded at extreme magnitudes (operands
112
+ beyond `2^53`), where converting each operand to a double before dividing
113
+ would otherwise lose precision.
114
+
115
+ ## [1.1.1] - 2026-06-17
116
+
117
+ ### Fixed
118
+
119
+ - Enable `embed-readme` so the README renders on npmjs.com.
120
+
121
+ ## [1.1.0] - 2026-06-17
122
+
123
+ ### Added
124
+
125
+ - SI metric prefix ladders generated across the metric dimensions.
126
+ - `Quantity` arithmetic — `plus` / `minus` / `times` / `dividedBy` (with
127
+ `add` / `sub` / `mul` / `div` aliases).
128
+ - `Quantity` comparison — `equals` / `notEquals` / `lessThan` / `greaterThan` /
129
+ `lessThanOrEqual` / `greaterThanOrEqual` (with `eq` / `ne` / `lt` / `gt` /
130
+ `lte` / `gte` aliases), plus `compareTo` as a sort comparator.
131
+ - `Quantity.ratioTo` for the dimensionless ratio between two quantities.
132
+ - `Quantity.abs` and the `Quantity.min` / `max` / `sum` statics.
133
+ - `clamp` as an instance method bounding a quantity to a range.
134
+ - `Quantity.toString`.
135
+ - `Quantity.isZero` / `isPositive` / `isNegative` predicates and `round`.
136
+
137
+ ## [1.0.0] - 2026-06-16
138
+
139
+ ### Added
140
+
141
+ - Initial release: the core conversion engine (`Dimension`, `Unit`, `Quantity`,
142
+ `MeasurementSystem`), string parsing via `Quantity.parse`, and the first set
143
+ of built-in dimensions and measurement systems.
144
+
145
+ [3.0.0]: https://github.com/mhuggins/measurable/compare/v2.0.0...v3.0.0
146
+ [2.0.0]: https://github.com/mhuggins/measurable/compare/v1.1.1...v2.0.0
147
+ [1.1.1]: https://github.com/mhuggins/measurable/compare/v1.1.0...v1.1.1
148
+ [1.1.0]: https://github.com/mhuggins/measurable/compare/v1.0.0...v1.1.0
149
+ [1.0.0]: https://github.com/mhuggins/measurable/releases/tag/v1.0.0
package/README.md CHANGED
@@ -3,9 +3,14 @@
3
3
  Convert between units of measurement, with batteries-included common units and
4
4
  first-class support for defining your own.
5
5
 
6
- - **No drift** — each unit defines a single transform to its dimension's base
7
- unit; reverse conversions are derived, never stored, so they can't fall out of
8
- sync.
6
+ - **Exact, no drift** — magnitudes are held as exact rational numbers and
7
+ conversions run in rational arithmetic, collapsing to a float only when you
8
+ read `.magnitude`. So `foot → inch` is exactly `12` (not `12.000000000000002`),
9
+ and chains and round trips like `liter → gallon → liter` come back to exactly
10
+ what you started with.
11
+ - **No redundant factors** — each unit defines a single transform to its
12
+ dimension's base unit; reverse conversions are derived, never stored, so they
13
+ can't fall out of sync.
9
14
  - **Free chaining** — any unit converts to any other in the same dimension
10
15
  (e.g. `mile → inch`) without you defining every pair.
11
16
  - **Affine units** — temperature scales (°C/°F/K) and anything else needing an
@@ -26,7 +31,7 @@ The package is split into three import paths so the core stays lean:
26
31
 
27
32
  | Import | What you get |
28
33
  | -------------------------- | ------------------------------------------------------------------------- |
29
- | `measurable` | The building blocks: `Quantity`, `Dimension`, `MeasurementSystem`, `Unit`, errors |
34
+ | `measurable` | The building blocks: `Quantity`, `Dimension`, `MeasurementSystem`, `Unit`, `Rational`, errors |
30
35
  | `measurable/dimensions` | Predefined dimensions and their units (`length`, `meter`, `volume`, …) |
31
36
  | `measurable/systems` | Predefined measurement systems (`metric`, `imperial`, `usCustomary`) |
32
37
 
@@ -34,7 +39,7 @@ The package is split into three import paths so the core stays lean:
34
39
 
35
40
  ```ts
36
41
  import { Quantity } from "measurable";
37
- import { meter, mile, celsius, fahrenheit } from "measurable/dimensions";
42
+ import { meter, mile, foot, inch, celsius, fahrenheit } from "measurable/dimensions";
38
43
 
39
44
  // Convert: `.to()` returns a Quantity, `.in()` returns a raw number.
40
45
  new Quantity(5, mile).to(meter).magnitude; // 8046.72
@@ -42,6 +47,10 @@ new Quantity(5, mile).in(meter); // 8046.72
42
47
 
43
48
  // Affine scales work the same way.
44
49
  new Quantity(100, celsius).in(fahrenheit); // 212
50
+
51
+ // Conversions are exact: magnitudes are rationals under the hood.
52
+ new Quantity(1, foot).in(inch); // 12 (not 12.000000000000002)
53
+ new Quantity(1, foot).to(inch).to(foot).magnitude; // 1 (exact round trip)
45
54
  ```
46
55
 
47
56
  ## Concepts
@@ -49,35 +58,110 @@ new Quantity(100, celsius).in(fahrenheit); // 212
49
58
  - **`Dimension`** — a kind of measurable quantity (length, volume, mass, …). It
50
59
  owns a canonical **base unit** and is where all conversion happens. A unit
51
60
  belongs to exactly one dimension.
52
- - **`Unit`** — a name plus a transform (`toBase` / `fromBase`) into its
53
- dimension's base unit. Created through a dimension's builder methods.
54
- - **`Quantity`** a magnitude paired with a unit (e.g. `5 kilometer`).
61
+ - **`Unit`** — a name plus a transform into its dimension's base unit: an exact
62
+ rational `scale`/`offset` for linear and affine units, or an arbitrary
63
+ function pair for `custom` ones. Created through a dimension's builder methods.
64
+ - **`Quantity`** — a magnitude paired with a unit (e.g. `5 kilometer`). The
65
+ magnitude is held as an exact **`Rational`**; `.magnitude` reads it as a
66
+ `number`.
67
+ - **`Rational`** — an exact rational number (`n / d` over `bigint`s) used for the
68
+ lossless arithmetic above. You rarely construct one directly, but can pass one
69
+ anywhere a magnitude or scale is expected.
55
70
  - **`MeasurementSystem`** — a cross-dimension tag (metric/imperial/…). A unit can
56
71
  belong to many; membership is optional and never affects whether a conversion
57
72
  is allowed.
58
73
 
74
+ ## Exact arithmetic
75
+
76
+ A linear conversion is inherently rational — a foot is exactly `3048/10000` m and
77
+ an inch exactly `254/10000` m, so a foot is exactly `12` inches. Storing those
78
+ ratios as binary floats and routing values through the base unit loses that
79
+ (`1 foot → inch` would give `12.000000000000002`).
80
+
81
+ Instead, each `Quantity` keeps its magnitude as an exact `Rational`, and
82
+ conversions/arithmetic run in rational arithmetic, collapsing to a `number` only
83
+ when you read `.magnitude` (or call `.in()`). Because nothing is collapsed
84
+ mid-chain, conversions compose without accumulating drift:
85
+
86
+ ```ts
87
+ import { Quantity } from "measurable";
88
+ import { liter, usGallon } from "measurable/dimensions";
89
+
90
+ // A round trip through an awkward ratio still lands exactly on 7.
91
+ new Quantity(7, liter).to(usGallon).to(liter).magnitude; // 7
92
+
93
+ // .rational exposes the underlying exact value (always in lowest terms).
94
+ new Quantity(7, liter).to(usGallon).rational; // Rational 125000000/67596639
95
+ ```
96
+
97
+ This is exact for linear and affine units. A conversion that passes through a
98
+ non-linear `custom` unit (e.g. a logarithmic scale) necessarily uses floating
99
+ point and recaptures the result as a rational, so it is best-effort there.
100
+
59
101
  ## Built-in dimensions
60
102
 
61
103
  Import any dimension or unit from `measurable/dimensions`:
62
104
 
63
- | Dimension | Base | Units (a selection) |
64
- | ------------- | ---------- | ----------------------------------------------------------------------------------- |
65
- | `length` | `meter` | `kilometer`, `centimeter`, `millimeter`, `inch`, `foot`, `yard`, `mile` |
66
- | `volume` | `liter` | `milliliter`, `us*`/`imperial*` `Gallon`/`Quart`/`Pint`/`Gill`/`FluidOunce`, `cup`, `tablespoon`, `teaspoon` |
67
- | `mass` | `gram` | `kilogram`, `milligram`, `tonne`, `pound`, `ounce`, `stone`, `shortTon`, `longTon` |
68
- | `time` | `second` | `millisecond`, `minute`, `hour`, `day`, `week` |
69
- | `temperature` | `kelvin` | `celsius`, `fahrenheit` |
70
- | `angle` | `radian` | `degree`, `gradian`, `turn` |
71
- | `force` | `newton` | `kilonewton`, `dyne`, `poundForce`, `kilogramForce` |
105
+ | Dimension | Base | Units (a selection) |
106
+ | -------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------ |
107
+ | `length` | `meter` | `kilometer`, `centimeter`, `millimeter`, `inch`, `foot`, `yard`, `mile` |
108
+ | `area` | `squareMeter` | `squareKilometer`, `hectare`, `are`, `squareInch`, `squareFoot`, `squareYard`, `acre`, `squareMile` |
109
+ | `volume` | `liter` | `milliliter`, `us*`/`imperial*` `Gallon`/`Quart`/`Pint`/`Gill`/`FluidOunce`, `cup`, `tablespoon`, `teaspoon` |
110
+ | `mass` | `gram` | `kilogram`, `milligram`, `tonne`, `pound`, `ounce`, `stone`, `shortTon`, `longTon` |
111
+ | `time` | `second` | `millisecond`, `minute`, `hour`, `day`, `week` |
112
+ | `temperature` | `kelvin` | `celsius`, `fahrenheit` |
113
+ | `angle` | `radian` | `degree`, `gradian`, `turn` |
114
+ | `force` | `newton` | `kilonewton`, `dyne`, `poundForce`, `kilogramForce` |
115
+ | `energy` | `joule` | `kilojoule`, `wattHour`, `kilowattHour`, `calorie`, `kilocalorie`, `britishThermalUnit` |
116
+ | `power` | `watt` | `kilowatt`, `megawatt`, `horsepower`, `metricHorsepower` |
117
+ | `pressure` | `pascal` | `kilopascal`, `bar`, `millibar`, `atmosphere`, `torr`, `psi`, `inchOfMercury`, `inchOfWater` |
118
+ | `frequency` | `hertz` | `kilohertz`, `megahertz`, `gigahertz`, `terahertz` |
119
+ | `data` | `bit` | `byte`, `nibble`, `kilobyte`…`petabyte` (SI), `kibibyte`…`pebibyte` (IEC) |
120
+ | `illuminance` | `lux` | `kilolux`, `millilux`, `footCandle`, `phot` |
121
+ | `luminance` | `candelaPerSquareMeter` | `nit`, `stilb` |
122
+ | `luminousIntensity` | `candela` | `kilocandela`, `millicandela`, `candlepower`, `hefnerkerze` |
72
123
 
73
124
  The metric units carry the **full SI prefix ladder** (yotta → yocto), generated for
74
- you: `length`, `mass`, `volume`, and `force` get every prefix (so `kilogram`
75
- itself is just the kilo-prefixed gram), while `time` and `angle` get the
76
- fractional prefixes only (e.g.
77
- `millisecond`, `microradian`). So `decimeter`, `hectometer`, `megagram`,
78
- `kiloliter`, `nanosecond`, etc. are all available and parse from their symbols
79
- (`dm`, `hm`, `Mg`, `kL`, `ns`, and `µm`/`um` for micro). You can apply the same
80
- ladder to your own dimensions with `definePrefixed` (see below).
125
+ you: the SI-based dimensions (`length`, `mass`, `volume`, `force`, `energy`, `power`,
126
+ `pressure`, `frequency`, `illuminance`, `luminousIntensity`) get every prefix (so
127
+ `kilogram` itself is just the kilo-prefixed gram), while `time` and `angle` get the
128
+ fractional prefixes only (e.g. `millisecond`, `microradian`). Every generated rung
129
+ parses from its symbol (`dm`, `hm`, `Mg`, `kL`, `ns`, `GHz`, `kPa`, and `µm`/`um` for
130
+ micro) and converts like any other unit. `data` additionally carries the **IEC binary**
131
+ multiples (`kibibyte`, `mebibyte`, = 1024-based) alongside the SI decimal ones
132
+ (`kilobyte`, … = 1000-based). You can apply the same ladder to your own dimensions with
133
+ `definePrefixed` (see below).
134
+
135
+ ### Prefixed unit exports
136
+
137
+ Each prefixed dimension exports a **record** holding the _complete_ generated ladder
138
+ keyed by name (the return value of `definePrefixed`), plus a **curated subset of those
139
+ same units as individual named exports** for convenience. Reach any rung that isn't
140
+ exported individually through the record — e.g. `metricLength.gigameter`,
141
+ `metricMass.exagram` — or by parsing its symbol.
142
+
143
+ | Dimension | Record export(s) | Individually exported units |
144
+ | ------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
145
+ | `length` | `metricLength` | `kilometer`, `hectometer`, `decameter`, `decimeter`, `centimeter`, `millimeter`, `micrometer`, `nanometer` |
146
+ | `mass` | `metricMass` | `kilogram`, `megagram`, `hectogram`, `decagram`, `decigram`, `centigram`, `milligram`, `microgram`, `nanogram` |
147
+ | `volume` | `metricVolume` | `kiloliter`, `hectoliter`, `decaliter`, `deciliter`, `centiliter`, `milliliter` |
148
+ | `time` | `metricTime` | `millisecond`, `microsecond`, `nanosecond`, `picosecond` |
149
+ | `angle` | `metricAngle` | `milliradian`, `microradian` |
150
+ | `force` | `metricForce` | `meganewton`, `kilonewton`, `millinewton`, `micronewton` |
151
+ | `energy` | `metricEnergy`, `metricWattHour` | `kilojoule`, `megajoule`, `gigajoule`, `millijoule`; `kilowattHour`, `megawattHour`, `gigawattHour` |
152
+ | `power` | `metricPower` | `kilowatt`, `megawatt`, `gigawatt`, `terawatt`, `milliwatt` |
153
+ | `pressure` | `metricPressure` | `kilopascal`, `hectopascal`, `megapascal`, `gigapascal` |
154
+ | `frequency` | `metricFrequency` | `kilohertz`, `megahertz`, `gigahertz`, `terahertz`, `petahertz`, `millihertz` |
155
+ | `illuminance` | `metricIlluminance` | `kilolux`, `millilux`, `microlux` |
156
+ | `luminousIntensity` | `metricLuminousIntensity` | `kilocandela`, `millicandela`, `microcandela` |
157
+ | `data` | `dataMultiples` | `kilobit`/`kilobyte` … `petabit`/`petabyte` (SI), `kibibit`/`kibibyte` … `pebibit`/`pebibyte` (IEC) |
158
+
159
+ ```ts
160
+ import { metricLength, kilometer } from "measurable/dimensions";
161
+
162
+ kilometer === metricLength.kilometer; // true — the named export is the same unit
163
+ metricLength.gigameter; // a rung not exported by name, reached via the record
164
+ ```
81
165
 
82
166
  ## Built-in measurement systems
83
167
 
@@ -92,6 +176,13 @@ usCustomary.has(foot); // true
92
176
  metric.has(foot); // false
93
177
  ```
94
178
 
179
+ Membership spans every dimension: SI units (`squareMeter`, `pascal`, `watt`, `joule`,
180
+ `hertz`, `lux`, `candela`, and their prefix ladders) are tagged into `metric`, while the
181
+ customary ones (`squareFoot`, `acre`, `psi`, `horsepower`, `britishThermalUnit`,
182
+ `footCandle`, `candlepower`) belong to both `imperial` and `usCustomary`. Units tied to
183
+ no real-world standard (e.g. `atmosphere`, `torr`, and the `data` multiples) stay
184
+ untagged — they still convert, they just won't appear under any system.
185
+
95
186
  ### Listing units in a system
96
187
 
97
188
  ```ts
@@ -115,9 +206,65 @@ metric.express(new Quantity(5000, meter)); // Quantity(5, kilometer)
115
206
  imperial.express(new Quantity(5000, meter)); // Quantity(3.107…, mile)
116
207
  ```
117
208
 
118
- A `Quantity` also has a `toString()` that renders `"<magnitude> <unit name>"`
119
- (e.g. `new Quantity(5, kilometer).toString()` → `"5 kilometer"`), and `round(decimals)`
120
- to trim the magnitude for display (`new Quantity(1.6213, mile).round(2)` `1.62 mile`).
209
+ ## Formatting output
210
+
211
+ Each unit carries a canonical `symbol` (`"g"`, `"km"`, `"°C"`) and `plural`
212
+ (`"grams"`, `"kilometers"`) alongside its `name`, so a `Quantity` can be rendered the
213
+ way you want:
214
+
215
+ ```ts
216
+ import { Quantity } from "measurable";
217
+ import { gram } from "measurable/dimensions";
218
+
219
+ new Quantity(5, gram).toString(); // "5 gram" (always the bare name)
220
+ new Quantity(5, gram).format(); // "5 grams" (magnitude-aware)
221
+ new Quantity(1, gram).format(); // "1 gram" (singular at ±1)
222
+ new Quantity(5, gram).format({ unit: "symbol" }); // "5 g"
223
+ new Quantity(5, gram).format({ unit: "name" }); // "5 gram"
224
+ new Quantity(5, gram).format({ unit: "plural" }); // "5 grams"
225
+
226
+ // Localize the magnitude with locale / numberFormat (passed to toLocaleString):
227
+ new Quantity(1234.5, meter).format({ locale: "de-DE" }); // "1.234,5 meters"
228
+ new Quantity(1.23456, meter).format({ numberFormat: { maximumFractionDigits: 2 } }); // "1.23 meters"
229
+ new Quantity(1234.5, kilometer).format({ locale: "de-DE", unit: "symbol" }); // "1.234,5 km"
230
+ ```
231
+
232
+ `toString()` is intentionally stable (`"<magnitude> <name>"`). `format(options?)` is the
233
+ flexible one: `unit` defaults to `"auto"` (singular `name` at ±1, otherwise `plural`) and
234
+ accepts `"name"`, `"plural"`, or `"symbol"`. When a unit has no `symbol`/`plural`, those
235
+ modes fall back to its `name`.
236
+
237
+ The magnitude is rendered with `Number.prototype.toLocaleString`. Pass `locale` (a BCP 47
238
+ locale or array) and/or `numberFormat` (`Intl.NumberFormatOptions` — precision via
239
+ `maximumFractionDigits`, grouping, `style`, …) to control it; with neither set, the
240
+ runtime's default locale is used. Use `round(decimals)` to trim the magnitude first
241
+ (`new Quantity(1.6213, mile).round(2)` → `1.62 mile`).
242
+
243
+ When a single string won't do — e.g. styling the magnitude in a React component — use
244
+ `formatParts(options?)`, which takes the same options but returns the rendered
245
+ `{ magnitude, unit }` as separate strings for you to assemble:
246
+
247
+ ```tsx
248
+ const { magnitude, unit } = new Quantity(1234.5, kilometer).formatParts({ locale: "de-DE" });
249
+ // { magnitude: "1.234,5", unit: "kilometers" }
250
+ return <><b>{magnitude}</b> {unit}</>;
251
+ ```
252
+
253
+ ### Internationalization
254
+
255
+ `format()` localizes the **magnitude** (via `locale`/`numberFormat`), but the **label** it
256
+ appends — `symbol`/`plural` — is **English/canonical** convenience data, not a localization
257
+ system: a single plural string can't model languages with several plural forms, and the
258
+ names themselves are English. For a fully localized label, delegate to `Intl.NumberFormat`,
259
+ whose `style: "unit"` localizes **and** pluralizes a curated set of units for you:
260
+
261
+ ```ts
262
+ const q = new Quantity(5, kilometer);
263
+ new Intl.NumberFormat("de", { style: "unit", unit: "kilometer" }).format(q.magnitude);
264
+ // "5 Kilometer"
265
+ new Intl.NumberFormat("fr", { style: "unit", unit: "kilometer", unitDisplay: "short" })
266
+ .format(q.magnitude); // "5 km"
267
+ ```
121
268
 
122
269
  ## Parsing strings
123
270
 
@@ -163,9 +310,10 @@ new Quantity(1, shortTon).in(tonne); // 0.90718474
163
310
 
164
311
  Quantities can be combined. `plus`/`minus` take another `Quantity` (converted into
165
312
  the receiver's unit first, so the operands may use different units of the same
166
- dimension); `times`/`dividedBy` apply a dimensionless scalar, and `negate`/`abs`
167
- transform the magnitude. All return a **new** `Quantity` in the receiver's unit and
168
- leave the operands untouched.
313
+ dimension); `times`/`dividedBy` apply a dimensionless scalar (a `number` or a
314
+ `Rational`), and `negate`/`abs` transform the magnitude. All return a **new**
315
+ `Quantity` in the receiver's unit and leave the operands untouched. Like
316
+ conversions, the arithmetic is exact — `q.times(3).dividedBy(3)` returns `q`.
169
317
 
170
318
  ```ts
171
319
  import { Quantity } from "measurable";
@@ -220,8 +368,11 @@ new Quantity(1, kilometer).greaterThan(new Quantity(1, meter)); // true
220
368
 
221
369
  Short aliases: **`eq`** (`equals`), **`ne`** (`notEquals`), **`lt`** (`lessThan`),
222
370
  **`gt`** (`greaterThan`), **`lte`** (`lessThanOrEqual`), **`gte`**
223
- (`greaterThanOrEqual`). Equality is exact, so values differing only by
224
- floating-point rounding from a conversion may compare unequal.
371
+ (`greaterThanOrEqual`). Comparison is exact rational comparison, so quantities
372
+ that are mathematically equal compare equal even when reaching them involved a
373
+ conversion that would have drifted in floating point — e.g.
374
+ `new Quantity(7, liter).to(usGallon).to(liter).equals(new Quantity(7, liter))` is
375
+ `true`.
225
376
 
226
377
  `compareTo(other)` returns `-1`, `0`, or `1`, suitable as an `Array#sort`
227
378
  comparator: `quantities.sort((a, b) => a.compareTo(b))`.
@@ -248,26 +399,55 @@ b.clamp(a, new Quantity(2, kilometer)); // b bounded to [a, 2 km], in b's unit
248
399
  ## Defining your own units
249
400
 
250
401
  Create a `Dimension` and add units through its builder methods. `scale` is how
251
- many base units make up one of the unit being defined.
402
+ many base units make up one of the unit being defined. The optional final argument
403
+ is a definition object — `{ symbol?, plural?, aliases? }` — whose `symbol` and
404
+ `plural` feed `format()` and, like `aliases`, are also registered for parsing.
252
405
 
253
406
  ```ts
254
407
  import { Dimension, Quantity } from "measurable";
255
408
 
256
409
  const data = new Dimension("data");
257
- const byte = data.base("byte", ["B", "bytes"]); // the base unit (identity)
258
- const kilobyte = data.unit("kilobyte", 1024, ["KB"]);
259
- const megabyte = data.unit("megabyte", 1024 ** 2, ["MB"]);
410
+ const byte = data.base("byte", { symbol: "B", plural: "bytes" }); // base unit (identity)
411
+ const kilobyte = data.unit("kilobyte", 1024, { symbol: "KB", plural: "kilobytes" });
412
+ const megabyte = data.unit("megabyte", 1024 ** 2, { symbol: "MB", plural: "megabytes" });
413
+
414
+ new Quantity(2, megabyte).in(kilobyte); // 2048
415
+ new Quantity(2, megabyte).format(); // "2 megabytes"
416
+ new Quantity(2, megabyte).format({ unit: "symbol" }); // "2 MB"
417
+ ```
418
+
419
+ A numeric `scale` is read as the exact decimal you wrote (`0.0254` → `254/10000`),
420
+ which is exact for any terminating decimal. For a ratio a decimal **can't**
421
+ represent exactly — e.g. `5/9` — pass a `Rational` so it stays exact:
260
422
 
261
- new Quantity(2, megabyte).in(kilobyte); // 2048
423
+ ```ts
424
+ import { Dimension, Rational } from "measurable";
425
+
426
+ const ratio = new Dimension("ratio");
427
+ ratio.base("whole");
428
+ const third = ratio.unit("third", new Rational(1, 3)); // exact, not 0.3333…
262
429
  ```
263
430
 
264
431
  ### Affine units (offset, not just scale)
265
432
 
266
433
  ```ts
434
+ import { Dimension, Rational } from "measurable";
435
+
267
436
  const temperature = new Dimension("temperature");
268
- const kelvin = temperature.base("kelvin", ["K"]);
437
+ const kelvin = temperature.base("kelvin", { symbol: "K" });
269
438
  // value_in_base = value * scale + offset
270
- const celsius = temperature.affine("celsius", { scale: 1, offset: 273.15 }, ["C"]);
439
+ const celsius = temperature.affine("celsius", { scale: 1, offset: 273.15 }, {
440
+ symbol: "°C",
441
+ aliases: ["C"],
442
+ });
443
+ // Fahrenheit's 5/9 isn't a terminating decimal — give it (and the derived
444
+ // offset) as exact Rationals so conversions round-trip without drift.
445
+ const scale = new Rational(5, 9);
446
+ const fahrenheit = temperature.affine(
447
+ "fahrenheit",
448
+ { scale, offset: Rational.from(273.15).minus(new Rational(32).times(scale)) },
449
+ { symbol: "°F", aliases: ["F"] },
450
+ );
271
451
  ```
272
452
 
273
453
  ### Fully custom transforms
@@ -285,18 +465,22 @@ dim.custom("squared", {
285
465
 
286
466
  ### Generating SI prefixes
287
467
 
288
- `definePrefixed` adds the metric prefix ladder to a reference unit and returns the
289
- created units keyed by name (skipping any name that already exists). Pass
290
- `SI_SUBMULTIPLE_PREFIXES` to generate fractions only.
468
+ `definePrefixed` adds the metric prefix ladder to a reference **unit** and returns the
469
+ created units keyed by name (skipping any name that already exists). It reads the
470
+ reference's `name`, `symbol`, and scale straight off the unit (via `scaleOf`), so each
471
+ generated unit gets a derived symbol (`b` → `kb`) and plural too — even when the
472
+ reference isn't the base unit. Pass `SI_SUBMULTIPLE_PREFIXES` to generate fractions only.
291
473
 
292
474
  ```ts
293
475
  import { Dimension, Quantity, definePrefixed } from "measurable";
294
476
 
295
477
  const data = new Dimension("data");
296
- const bit = data.base("bit", ["b"]);
297
- const prefixed = definePrefixed(data, { name: "bit", symbol: "b", scale: 1 });
478
+ const bit = data.base("bit", { symbol: "b", plural: "bits" });
479
+ const prefixed = definePrefixed(data, bit);
298
480
 
299
- new Quantity(1, prefixed.kilobit).in(bit); // 1000 (SI kilo = 1e3)
481
+ new Quantity(1, prefixed.kilobit).in(bit); // 1000 (SI kilo = 1e3)
482
+ prefixed.kilobit.symbol; // "kb"
483
+ new Quantity(5, prefixed.kilobit).format({ unit: "symbol" }); // "5 kb"
300
484
  ```
301
485
 
302
486
  ### Tagging units into a measurement system
@@ -313,32 +497,43 @@ si.has(kilobyte); // true
313
497
  ### `Dimension`
314
498
 
315
499
  - `new Dimension(name)`
316
- - `.base(name, aliases?)` — define the canonical base unit
317
- - `.unit(name, scale, aliases?)` — linear unit (`scale` base units per unit)
318
- - `.affine(name, { scale, offset }, aliases?)` — linear with additive offset
319
- - `.custom(name, { toBase, fromBase }, aliases?)` — arbitrary inverse pair
320
- - `.convert(value, from, to)` — convert a raw number between two of its units
500
+ - `.base(name, def?)` — define the canonical base unit
501
+ - `.unit(name, scale, def?)` — linear unit (`scale` base units per unit; `number | Rational`)
502
+ - `.affine(name, { scale, offset }, def?)` — linear with additive offset (each `number | Rational`)
503
+ - `.custom(name, { toBase, fromBase }, def?)` — arbitrary inverse pair, for non-linear units
504
+ - `.convert(value, from, to)` — convert a raw `number` between two of its units
505
+ - `.convertRational(value, from, to)` → `Rational` — exact conversion between two of its units
321
506
  - `.get(token)` — units matching a name/alias (`Unit[] | undefined`)
322
507
  - `.has(unit)`, `.units`, `.baseUnit`
323
508
 
509
+ `def` is an optional `UnitDef`: `{ symbol?, plural?, aliases? }`. All three are
510
+ registered as parse tokens; `symbol`/`plural` are additionally stored on the `Unit`.
511
+
324
512
  ### `Unit`
325
513
 
326
514
  A passive handle, normally created via a dimension's builder methods rather than
327
515
  `new Unit` directly. Read-only properties:
328
516
 
329
517
  - `.name` — the unit's canonical name
518
+ - `.symbol?` — canonical symbol (e.g. `"g"`, `"km"`), if declared
519
+ - `.plural?` — plural name (e.g. `"grams"`), if declared
330
520
  - `.dimension` — the `Dimension` it belongs to
521
+ - `.linear` → `{ scale: Rational; offset: Rational } | undefined` — the exact transform for linear/affine units (`undefined` for `custom` ones)
331
522
  - `.toBase(value)` → `number` — convert a value in this unit to base units
332
523
  - `.fromBase(value)` → `number` — convert a value in base units to this unit
333
524
 
334
525
  ### `Quantity`
335
526
 
336
- - `new Quantity(magnitude, unit)`
527
+ - `new Quantity(magnitude, unit)` — `magnitude` is a `number | Rational`; throws on a non-finite `number`
528
+ - `.magnitude` → `number` — getter, derived from `.rational`
529
+ - `.rational` → `Rational` — the exact magnitude (source of truth)
337
530
  - `.to(target)` → `Quantity`
338
531
  - `.in(target)` → `number`
339
- - `.toString()` → `string` — e.g. `"5 kilometer"`
532
+ - `.toString()` → `string` — stable `"<magnitude> <name>"`, e.g. `"5 kilometer"`
533
+ - `.format({ unit?, locale?, numberFormat? })` → `string` — `unit`: `"auto"` (default, magnitude-aware) / `"name"` / `"plural"` / `"symbol"`; `locale` + `numberFormat` localize the magnitude via `toLocaleString`
534
+ - `.formatParts(options?)` → `{ magnitude, unit }` — same options as `.format`, but returns the rendered pieces separately for custom assembly (e.g. JSX)
340
535
  - `.plus(other)` / `.minus(other)` → `Quantity` — add/subtract another quantity (aliases: `add` / `sub`)
341
- - `.times(factor)` / `.dividedBy(divisor)` → `Quantity` — scale by a number (aliases: `mul` / `div`)
536
+ - `.times(factor)` / `.dividedBy(divisor)` → `Quantity` — scale by a `number | Rational` (aliases: `mul` / `div`)
342
537
  - `.ratioTo(other)` → `number` — dimensionless ratio (how many of `other` fit in this)
343
538
  - `.negate()` / `.abs()` → `Quantity`
344
539
  - `.clamp(lower, upper)` → `Quantity` — bound to a range, in this unit
@@ -351,6 +546,23 @@ A passive handle, normally created via a dimension's builder methods rather than
351
546
  - `Quantity.min(...quantities)` / `Quantity.max(...quantities)` / `Quantity.sum(...quantities)` → `Quantity`
352
547
  - `Quantity.parse(input, dimension, { prefer? })` → `Quantity`
353
548
 
549
+ ### `Rational`
550
+
551
+ An exact rational number (`n / d`), stored as `bigint`s in lowest terms with a
552
+ positive denominator. Immutable; every operation returns a new `Rational`. Used
553
+ internally for lossless conversions and arithmetic, but you can construct one to
554
+ pass anywhere a magnitude or scale is accepted.
555
+
556
+ - `new Rational(numerator, denominator?)` — from integers (`bigint | number`; denominator defaults to `1`). Throws on a non-integer `number` or a zero denominator.
557
+ - `Rational.from(value)` — coerce a `number | Rational` (a `number` is read as its exact terminating decimal, e.g. `0.0254` → `254/10000`)
558
+ - `.n` / `.d` → `bigint` — numerator and denominator
559
+ - `.plus(other)` / `.minus(other)` / `.times(other)` / `.dividedBy(other)` → `Rational` (aliases: `add` / `sub` / `mul` / `div`)
560
+ - `.negate()` / `.abs()` → `Rational`
561
+ - `.equals(other)` → `boolean` (alias: `eq`)
562
+ - `.compare(other)` → `-1 | 0 | 1`
563
+ - `.sign()` → `-1 | 0 | 1`
564
+ - `.toNumber()` → `number` — collapse to the nearest `number`
565
+
354
566
  ### `MeasurementSystem`
355
567
 
356
568
  - `new MeasurementSystem(name)`
@@ -364,6 +576,12 @@ A passive handle, normally created via a dimension's builder methods rather than
364
576
  - `UnknownUnitError` — a parsed token matches no unit
365
577
  - `AmbiguousUnitError` — a parsed token matches several units and no `prefer` was given
366
578
 
579
+ ## Changelog
580
+
581
+ See [CHANGELOG.md](https://github.com/mhuggins/measurable/blob/main/CHANGELOG.md)
582
+ for release notes. Note that v2.0.0 is a breaking release — see its entry for the
583
+ migration details.
584
+
367
585
  ## License
368
586
 
369
587
  ISC
@@ -2,13 +2,20 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.microradian = exports.milliradian = exports.metricAngle = exports.turn = exports.gradian = exports.degree = exports.radian = exports.angle = void 0;
4
4
  const Dimension_1 = require("../lib/Dimension");
5
- const prefixes_1 = require("../lib/prefixes");
5
+ const definePrefixed_1 = require("../utils/definePrefixed");
6
6
  /** Plane angle. Base unit: radian. */
7
7
  exports.angle = new Dimension_1.Dimension("angle");
8
- exports.radian = exports.angle.base("radian", ["rad", "radians"]);
9
- exports.degree = exports.angle.unit("degree", Math.PI / 180, ["deg", "°", "degrees"]);
10
- exports.gradian = exports.angle.unit("gradian", Math.PI / 200, ["grad", "gradians"]);
11
- exports.turn = exports.angle.unit("turn", 2 * Math.PI, ["turns", "revolution", "revolutions"]);
8
+ exports.radian = exports.angle.base("radian", { symbol: "rad", plural: "radians" });
9
+ exports.degree = exports.angle.unit("degree", Math.PI / 180, {
10
+ symbol: "°",
11
+ plural: "degrees",
12
+ aliases: ["deg"],
13
+ });
14
+ exports.gradian = exports.angle.unit("gradian", Math.PI / 200, { symbol: "grad", plural: "gradians" });
15
+ exports.turn = exports.angle.unit("turn", 2 * Math.PI, {
16
+ plural: "turns",
17
+ aliases: ["revolution", "revolutions"],
18
+ });
12
19
  /** SI-submultiple radians (milliradian, microradian, …); larger angles use degree/turn. */
13
- exports.metricAngle = (0, prefixes_1.definePrefixed)(exports.angle, { name: "radian", symbol: "rad", scale: 1 }, prefixes_1.SI_SUBMULTIPLE_PREFIXES);
20
+ exports.metricAngle = (0, definePrefixed_1.definePrefixed)(exports.angle, exports.radian, definePrefixed_1.SI_SUBMULTIPLE_PREFIXES);
14
21
  exports.milliradian = exports.metricAngle.milliradian, exports.microradian = exports.metricAngle.microradian;