@onrail-xyz/amount 1.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 (47) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +617 -0
  3. package/dist/aggregating.d.ts +31 -0
  4. package/dist/aggregating.d.ts.map +1 -0
  5. package/dist/aggregating.js +31 -0
  6. package/dist/allocating.d.ts +9 -0
  7. package/dist/allocating.d.ts.map +1 -0
  8. package/dist/allocating.js +31 -0
  9. package/dist/amount.d.ts +68 -0
  10. package/dist/amount.d.ts.map +1 -0
  11. package/dist/amount.js +184 -0
  12. package/dist/format.d.ts +15 -0
  13. package/dist/format.d.ts.map +1 -0
  14. package/dist/format.js +226 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/jsonCodecs.d.ts +9 -0
  19. package/dist/jsonCodecs.d.ts.map +1 -0
  20. package/dist/jsonCodecs.js +46 -0
  21. package/dist/kind.d.ts +126 -0
  22. package/dist/kind.d.ts.map +1 -0
  23. package/dist/kind.js +118 -0
  24. package/dist/rate.d.ts +58 -0
  25. package/dist/rate.d.ts.map +1 -0
  26. package/dist/rate.js +178 -0
  27. package/dist/rational.d.ts +49 -0
  28. package/dist/rational.d.ts.map +1 -0
  29. package/dist/rational.js +378 -0
  30. package/dist/segmenting.d.ts +15 -0
  31. package/dist/segmenting.d.ts.map +1 -0
  32. package/dist/segmenting.js +97 -0
  33. package/dist/unitSpecs.d.ts +47 -0
  34. package/dist/unitSpecs.d.ts.map +1 -0
  35. package/dist/unitSpecs.js +8 -0
  36. package/package.json +56 -0
  37. package/src/aggregating.ts +160 -0
  38. package/src/allocating.ts +70 -0
  39. package/src/amount.ts +291 -0
  40. package/src/format.ts +330 -0
  41. package/src/index.ts +8 -0
  42. package/src/jsonCodecs.ts +63 -0
  43. package/src/kind.ts +442 -0
  44. package/src/rate.ts +316 -0
  45. package/src/rational.ts +471 -0
  46. package/src/segmenting.ts +154 -0
  47. package/src/unitSpecs.ts +37 -0
package/README.md ADDED
@@ -0,0 +1,617 @@
1
+ # @onrail-xyz/amount
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@onrail-xyz/amount.svg)](https://www.npmjs.com/package/@onrail-xyz/amount)
4
+
5
+ Type-safe handling of amounts with units and arbitrary-precision arithmetic.
6
+
7
+ ## Why?
8
+
9
+ ```typescript
10
+ // Seconds or milliseconds?
11
+ function retry(timeout: number) { ... }
12
+
13
+ // From your config - quick: is this 0.15 ETH, 1.5 ETH, or 15 ETH? Or is it even ETH at all?
14
+ JSON.parse('{ "maxTransfer": "1500000000000000000" }')
15
+
16
+ // A 2% fee on a bigint
17
+ const fee = amount * 2n / 100n; // annoying AF
18
+ ```
19
+
20
+ But hey, it works, right? Not like anyone ever [lost money because they got their orders of magnitude wrong](https://x.com/threesigmaxyz/status/1929838159019299072), or a [Mars Orbiter](https://en.wikipedia.org/wiki/Mars_Climate_Orbiter) because of unit or dimensional mixups.
21
+
22
+ Now you could:
23
+ 1. pray this won't happen to you
24
+ 2. chest thump and say this won't happen to you
25
+ 3. drown yourself in branded types and a bunch of utility functions to handle different currencies, different systems of unit, ...
26
+
27
+ ... or you could use this package, which gives you that rigor without the headache/boilerplate:
28
+
29
+ ```typescript
30
+ // unambiguous
31
+ const timeout = duration(30, "seconds");
32
+
33
+ // convenient and readable ...
34
+ const maxTransfer = eth(1.5);
35
+
36
+ // ... even in your config
37
+ maxTransfer.toJSON(); //"1.5 ETH"
38
+
39
+ // define prices/rates ...
40
+ const ethPrice = usd(3_000).per(ETH);
41
+
42
+ // ... and apply them in a type-safe manner
43
+ const maxInUsd = maxTransfer.mul(ethPrice); // == usd(4_500)
44
+
45
+ // gives you the atomic unit (wei, sats,...) no matter if ETH, BTC, ...
46
+ amount.in("atomic");
47
+
48
+ // just works
49
+ const withFee = amount.mul(1.02);
50
+ ```
51
+
52
+ ## Quick Start
53
+
54
+ ```typescript
55
+ import { Amount, kind } from "@onrail-xyz/amount";
56
+
57
+ // Define a currency with units
58
+ const ETH = kind(
59
+ "ETH",
60
+ [ { symbols: [{ symbol: "ETH" }] },
61
+ { symbols: [{ symbol: "Gwei" }], oom: -9 },
62
+ { symbols: [{ symbol: "wei" }], oom: -18 },
63
+ ],
64
+ { human: "ETH", atomic: "wei" }
65
+ );
66
+ const eth = Amount.ofKind(ETH);
67
+
68
+ // Create and manipulate amounts
69
+ const balance = eth(1.5);
70
+ balance.in("ETH"); // Rational(1.5)
71
+ balance.in("Gwei"); // Rational(1_500_000_000)
72
+ balance.in("atomic"); // 1_500_000_000_000_000_000n
73
+
74
+ // Arithmetic
75
+ balance.mul(2); // 3 ETH
76
+ balance.add(eth(1)); // 2.5 ETH
77
+
78
+ // Parse from strings
79
+ Amount.parse("25 Gwei", ETH); // == eth(25, "Gwei")
80
+
81
+ // Convert between kinds
82
+ const USD = kind(
83
+ "USD",
84
+ [{ symbols: [{ symbol: "$", spacing: "compact", position: "prefix" }] }],
85
+ { human: "$" }
86
+ );
87
+ const usd = Amount.ofKind(USD);
88
+
89
+ const ethPrice = usd(3000).per(ETH);
90
+ eth(0.5).mul(ethPrice); // $1,500
91
+ ```
92
+
93
+ ## Install
94
+
95
+ ```bash
96
+ npm install @onrail-xyz/amount @onrail-xyz/utils
97
+ ```
98
+
99
+ [@onrail-xyz/utils](https://github.com/Onrail/ts-sdk/tree/main/packages/utils) is a peer dependency. No other runtime dependencies — arithmetic is exact, built on native `bigint`.
100
+
101
+ Like the rest of the SDK, it requires [TypeScript 7](https://github.com/Onrail/ts-sdk#requirements).
102
+
103
+ That's the pitch. The rest is reference — one front-to-back read pays off, but it's built for jumping:
104
+
105
+ * [Rational](#rational) — exact arbitrary-precision fractions; what `toString` guarantees
106
+ * [Kind](#kind) — unit ladders: [decimal](#decimal-kinds) · [scale](#scale-kinds) · [symbol options](#symbol-options) · [multi-system](#multi-system-kinds) · [`human`/`atomic`](#the-human-and-atomic-designations) · [scalar](#scalar-kinds) · [helpers](#helpers) · [publishing kinds](#publishing-named-kinds)
107
+ * [Amount](#amount) — value + kind: construction, unit conversion, rounding, arithmetic, [formatting](#formatting)
108
+ * [Rate](#rate) — one kind per another: prices, `inv`, `combine`, `cancel`
109
+ * [Type Narrowing](#type-narrowing) — guards for kind unions
110
+ * [Kind-Generic Code](#kind-generic-code) — generic over `K`, without casts
111
+ * [JSON Codecs](#json-codecs) — round-tripping amounts, rates, and rationals through `jsonStringify`/`jsonParse`
112
+ * [Limitations](#limitations) — [symbol characters](#symbol-characters) · [locale](#locale) · [ratios, not dimensional algebra](#ratios-not-dimensional-algebra) · [linear systems only](#linear-unit-systems-only) · [performance](#performance)
113
+
114
+ ## Core Concepts
115
+
116
+ `Rational`, `Amount` and `Rate` are immutable value types: every operation returns a new instance, nothing is ever modified in place.
117
+
118
+ ### Rational
119
+
120
+ Arbitrary-precision rational numbers for exact arithmetic. Stored as normalized fractions.
121
+
122
+ ```typescript
123
+ import { Rational } from "@onrail-xyz/amount";
124
+
125
+ Rational.from(5); // from integer
126
+ Rational.from(0.5); // from float (exact — see below)
127
+ Rational.from(5n, 2n); // from bigint numerator/denominator
128
+ Rational.from("1.5"); // from decimal string
129
+ Rational.from("1/3"); // from ratio string
130
+ Rational.from("333 1/3"); // from mixed-number string
131
+ ```
132
+
133
+ `from` reads a `number` the way it was most plausibly written. A value that prints as a decimal of up to 15 significant digits *is* that decimal — `Rational.from(1234.56789)` and `Rational.from("1234.56789")` are always the same value. Past that, the compact fraction the double is the rounding of is recovered when it is too accurate to be a coincidence — `1/3` comes back as exactly `1/3`, not `0.3333333333333333`, and `Rational.from(1/3).mul(3)` is exactly 1 — and everything else keeps the exact decimal the double prints as. Nothing is ever approximated.
134
+
135
+ The two spellings only part ways once the double stops naming the decimal that was meant — after arithmetic (`Rational.from(0.1 + 0.2)` is `0.30000000000000004`, not `3/10`) or past 15 significant digits. Pass a string when the literal is the truth.
136
+
137
+ String inputs are en-US: `.` is the decimal point, `,` and `_` are thousands separators in groups of three, and the two cannot mix. Anything else throws rather than guessing — `"1.000,5"` and `"1,00"` both fail — with the one unavoidable exception that `"1.000"` is one, not a thousand.
138
+
139
+ Supports standard arithmetic (`add`, `sub`, `mul`, `div`, `mod`, `neg`, `abs`, `inv`), comparison (`eq`, `ne`, `lt`, `le`, `gt`, `ge`), queries (`sign`, `isInteger`, `decimalPlaces`), and conversion (`floor`, `ceil`, `round`, `toNumber`, `toFixed`). `mod` is floored — the result carries the divisor's sign, consistent with `div` + `floor` (unlike `%` on `bigint`/`number`, which truncates).
140
+
141
+ `Rational.powerOfTen(n)` builds 10ⁿ for either sign of `n`; the free `tenToThe(n)` is its `bigint` counterpart, and takes non-negative exponents only. `unwrap()` hands back the underlying `[numerator, denominator]` pair.
142
+
143
+ `toString` is **exact**: the terminating decimal expansion when there is one (`"0.5"`), the mixed-number form otherwise (`"333 1/3"`) — either way `Rational.from` parses it back to the same value. There is no display precision to configure, and no value ever silently renders as `"0"`; for fixed-precision display, use `toFixed`.
144
+
145
+ ### Kind
146
+
147
+ A `Kind` defines a dimension (like currency, time, or data) with its units and their relationships. Its name is its identity: two definitions sharing a name are one kind to `sameKind`, to the runtime check behind `add` and its siblings, and to JSON decoding, so they must agree on their standard unit and on the scale of every symbol they share. The package assumes that consistency rather than checking it.
148
+
149
+ #### Decimal Kinds
150
+
151
+ For dimensions where units relate by powers of 10, use `oom` (order of magnitude):
152
+
153
+ ```typescript
154
+ import { kind } from "@onrail-xyz/amount";
155
+
156
+ const ETH = kind(
157
+ "ETH",
158
+ [ { symbols: [{ symbol: "ETH" }] }, // oom: 0 (implicit)
159
+ { symbols: [{ symbol: "Gwei" }], oom: -9 },
160
+ { symbols: [{ symbol: "wei" }], oom: -18 },
161
+ ],
162
+ { human: "ETH", atomic: "wei" }
163
+ );
164
+ ```
165
+
166
+ The first unit is the standard unit (scale = 1) — the types only let it claim `oom: 0`, `scale: 1`, or nothing. Other units are defined relative to it: `oom: -9` means the unit is 10⁻⁹ of the standard. A unit written this way is a `DecimalSpec`; the `scale` form below is a `ScaleSpec`.
167
+
168
+ #### Scale Kinds
169
+
170
+ For dimensions with arbitrary scale ratios, use `scale`. The spelling is what decides the system's character: a ladder spelled with `scale` is a scale system even where the factors happen to be powers of ten, and only `oom` ladders carry the decimal metadata that `getDecimals` and unit promotion read.
171
+
172
+ ```typescript
173
+ const Duration = kind(
174
+ "Duration",
175
+ [ { symbols: [{ symbol: "second", plural: "seconds" }] },
176
+ { symbols: [{ symbol: "minute", plural: "minutes" }], scale: 60n },
177
+ { symbols: [{ symbol: "hour", plural: "hours" }], scale: 3600n },
178
+ { symbols: [{ symbol: "day", plural: "days" }], scale: 86400n },
179
+ ]
180
+ );
181
+ ```
182
+
183
+ #### Symbol Options
184
+
185
+ Each unit can have multiple symbols with display options ("spaced" and "postfix" are default):
186
+
187
+ ```typescript
188
+ const USD = kind(
189
+ "USD",
190
+ [ { symbols: [
191
+ { symbol: "$", spacing: "compact", position: "prefix" },
192
+ { symbol: "USD" },
193
+ ]},
194
+ { symbols: [
195
+ { symbol: "¢", spacing: "compact" },
196
+ { symbol: "c", spacing: "compact" },
197
+ { symbol: "cent", plural: "cents" },
198
+ ], oom: -2 },
199
+ ],
200
+ { human: "$", atomic: "¢" },
201
+ );
202
+ const usd = Amount.ofKind(USD);
203
+
204
+ // Formatting respects these options:
205
+ const amt = usd(100);
206
+ amt.toString(); // "$100"
207
+ amt.toString("inUnit", "USD"); // "100 USD"
208
+ amt.toString("inUnit", "c"); // "10,000c"
209
+
210
+ const centAmt = usd(50, "c");
211
+ centAmt.toString(); // "50¢"
212
+ ```
213
+
214
+ Options:
215
+ - `position`: `"prefix"` or `"postfix"` (default)
216
+ - `spacing`: `"spaced"` (default) or `"compact"`
217
+ - `plural`: alternate symbol when the displayed magnitude is not 1 (`-1 hour`)
218
+
219
+ Those three together with `symbol` are a `SymbolSpec`.
220
+
221
+ The first symbol for each oom/scale is always the default unit for display. Other symbols can be used for convenience ("¢" prints nicely but is impossible to type, while "c" is easy) or when a certain symbol is desired (as in the example).
222
+
223
+ #### Multi-System Kinds
224
+
225
+ Some dimensions have multiple unit systems (e.g., metric vs imperial):
226
+
227
+ ```typescript
228
+ const inch = Rational.from(254n, 10000n); // 0.0254 m
229
+
230
+ const Length = kind(
231
+ "Length",
232
+ [
233
+ ["metric", [
234
+ { symbols: [{ symbol: "m" }] },
235
+ { symbols: [{ symbol: "cm" }], oom: -2 },
236
+ { symbols: [{ symbol: "km" }], oom: 3 },
237
+ ]],
238
+ ["imperial", [
239
+ { symbols: [{ symbol: "in" }], scale: inch },
240
+ { symbols: [{ symbol: "ft" }], scale: inch.mul(12) },
241
+ { symbols: [{ symbol: "mi" }], scale: inch.mul(63360) },
242
+ ]],
243
+ ],
244
+ );
245
+
246
+ // Format in different systems
247
+ const height = Amount.from(1.78, Length, "m");
248
+ height.toString(); // "1.78 m" (standard system, standard unit)
249
+ height.toString("imperial"); // "5 ft 10 in" (compound)
250
+ ```
251
+
252
+ The first system is the "standard" system and the first unit in it is the "standard" unit. Scale systems render across their units (e.g., "5 ft 10 in", "5 hours 10 minutes 1 second").
253
+
254
+ #### The `human` and `atomic` Designations
255
+
256
+ These provide a uniform interface across kinds:
257
+
258
+ ```typescript
259
+ // Generic code that works with any kind
260
+ function humanValue<K extends KindWithHuman>(amount: Amount<K>): Rational {
261
+ return amount.in("human"); // ETH, USD, meters, etc.
262
+ }
263
+
264
+ function toChainFormat<K extends KindWithAtomic>(amount: Amount<K>): bigint {
265
+ return amount.in("atomic"); // wei, satoshis, lamports, etc.
266
+ }
267
+ ```
268
+
269
+ - `human`: The unit people naturally think in (ETH, USD, meters) — the default unit of `Amount.from` and the `Amount.ofKind` factories, what the numeric `Rate` forms read, and the unit a zero displays in. A non-zero value picks its own display unit (see [Formatting](#formatting)).
270
+ - `atomic`: The indivisible unit for storage/transmission (wei, cents, mm) — `in("atomic")` is a `bigint`, and floors (see [Amount](#amount)).
271
+
272
+ Note: `atomic` is context-dependent. Lamports are atomic for SOL transfers, but compute prices use microlamports. The designation reflects the common case for a particular domain.
273
+
274
+ `getDecimals(kind)` reads the decimal distance between the two (especially useful for tokens) and takes an explicit `{ of, in }` pair to measure any other decimal units instead.
275
+
276
+ The constraints come as a family: `KindWithHuman`, `KindWithAtomic`, `KindWithHumanAndAtomic`, and `KindWithDecimalHumanAndAtomic`, which additionally pins both to decimal ladders — what `getDecimals` needs in order to work without an explicit pair. Each is `Kind` with its type parameters narrowed, so a custom constraint is spelled the same way, over `SystemInfo` and `ValidStandardInfo` (`@onrail-xyz/common`'s `CurrencyKind` is one).
277
+
278
+ #### Scalar Kinds
279
+
280
+ For dimensionless quantities (percentages, multipliers), use `scalar`:
281
+
282
+ ```typescript
283
+ import { scalar } from "@onrail-xyz/amount";
284
+
285
+ const Percentage = scalar(kind(
286
+ "Percentage",
287
+ [ { symbols: [{ symbol: "x" }] },
288
+ { symbols: [{ symbol: "%" }], oom: -2 },
289
+ { symbols: [{ symbol: "bps" }], oom: -4 }, // basis points
290
+ ],
291
+ { human: "%" }
292
+ ));
293
+ const percent = Amount.ofKind(Percentage);
294
+
295
+ const fee = percent(10);
296
+ const total = usd(1000);
297
+ total.mul(fee); // $100
298
+ total.div(fee); // $10,000
299
+ percent(10).mul(percent(50)); // 5 % — scalar × scalar stays scalar
300
+ percent(10).div(percent(50)); // 20 % — and so does scalar ÷ scalar
301
+ percent(10).ratio(percent(50)); // Rational(0.2) — the quotient of one kind is a plain number
302
+ ```
303
+
304
+ `scalar` asserts that the kind's standard unit is the plain multiplier: `mul`/`div` read a scalar operand in standard units. So `%` sits at `oom: -2` below `x`, not `x` at `oom: 2` above `%` — the latter reads `percent(10)` as a factor of 10 instead of 0.1.
305
+
306
+ The quotient of two amounts of one kind is `ratio`, not a `div` overload. A generic `K` may be instantiated as a union — code over "one of these kinds, known at runtime" — and at `K1 | K2` an overload taking `this` would accept an operand of the other kind, a scalar one included, which `div` scales by; nothing at runtime could tell which reading the types had chosen. So `div` takes scalars only, and `ratio` checks the kind at runtime like `add` and `sub` do.
307
+
308
+ Scalar kinds also compose into `Rate`s like any other kind — this is how dimensions like "per time" are expressed:
309
+
310
+ ```typescript
311
+ const apr = percent(5).per(Amount.parse("365 days", Duration)); // Rate<Percentage, Duration>
312
+ ```
313
+
314
+ #### Helpers
315
+
316
+ `identifyKind(kinds, str)` picks the one kind of a set whose units cover a string's symbols — the search `Amount.parse` runs over its candidate list. `getUnit(kind, symbol)` resolves any symbol, meta symbols included, to its `Unit` record.
317
+
318
+ Unit ladders are spelled more concisely as rows of scale and symbols. `toDecimalUnits` takes `number` exponents, `toScaleUnits` takes `Rationalish` scales, and both demand a nonempty tuple of `SymbolSpec`s per row — a malformed row is an error at the row, not at the `kind` call it feeds:
319
+
320
+ ```typescript
321
+ toDecimalUnits([
322
+ [0, [{ symbol: "FOO" }]], // oom: 0 (base unit)
323
+ [-6, [{ symbol: "µFOO" }]], // oom: -6 (micro)
324
+ ]);
325
+
326
+ toScaleUnits([
327
+ [1, [withPluralS("second")]], // scale: 1
328
+ [60, [withPluralS("minute")]], // scale: 60
329
+ ]);
330
+
331
+ withPluralS("hour"); // => { symbol: "hour", plural: "hours" }
332
+ allowPluralS("hour"); // => [{ symbol: "hour" }, { symbol: "hours" }]
333
+ allowOtherCap("Gwei"); // => [{ symbol: "Gwei" }, { symbol: "gwei" }]
334
+ allowPluralSBothCaps("sat"); // => sat, sats, Sat, Sats
335
+ withPluralSBothCaps("sat"); // => sat (plural sats), Sat (plural Sats)
336
+ ```
337
+
338
+ `@onrail-xyz/common` ships ready-made kinds — USDC, percentages, durations — built with these; it doubles as a rich source of examples.
339
+
340
+ #### Publishing Named Kinds
341
+
342
+ The exact structural type returned by `kind()` is deliberately rich: it preserves the kind name, every unit symbol, its system structure, the `human`/`atomic` designations, and the brands that make invalid combinations unrepresentable. That is what powers the package's type safety. It is also far too large to make a good public name.
343
+
344
+ For a kind exported by a library, give that structure a durable interface name and publish the value through it:
345
+
346
+ ```typescript
347
+ import { Amount, kind } from "@onrail-xyz/amount";
348
+ import type { Identity } from "@onrail-xyz/utils";
349
+
350
+ const _Token = kind(
351
+ "Token",
352
+ [ { symbols: [{ symbol: "TOK" }] },
353
+ { symbols: [{ symbol: "µTOK" }], oom: -6 } ],
354
+ { human: "TOK", atomic: "µTOK" },
355
+ );
356
+
357
+ export interface TokenKind extends Identity<typeof _Token> {}
358
+ export const Token = _Token as TokenKind;
359
+
360
+ export type Token = Amount<TokenKind>;
361
+ export const token = Amount.ofKind(Token);
362
+ ```
363
+
364
+ The interface is not merely an IntelliSense cleanup. A `type TokenKind = typeof _Token` alias can be expanded back into its full structure when TypeScript emits declarations or carries the type through downstream inference. The interface has its own symbol identity, so generated `.d.ts` files can keep printing `TokenKind`; that avoids duplicating the kind machinery throughout a consumer-facing API and preserves the checker's ability to reuse named instantiations. `Identity` exposes the inferred members in a form an interface may extend without changing them.
365
+
366
+ The last two declarations are the usual public surface:
367
+
368
+ * `Token` in type position means an amount of this kind;
369
+ * `Token` in value position is the kind object;
370
+ * `token(...)` is the convenient amount factory.
371
+
372
+ This is the same type/value namespace split that classes use. It keeps call sites terse without losing the explicit `TokenKind` name when an API really does operate on kind values:
373
+
374
+ ```typescript
375
+ function transfer(amount: Token) {
376
+ const transferCost = token(1);
377
+ return amount.sub(transferCost);
378
+ }
379
+ ```
380
+
381
+ For the mechanics behind the amount aliases themselves, see [Amount.md](https://github.com/Onrail/ts-sdk/blob/main/packages/amount/Amount.md). For the declaration-emission reason to prefer a traveling interface name, see [DeclarationEmit.md](https://github.com/Onrail/ts-sdk/blob/main/DeclarationEmit.md).
382
+
383
+ ### Amount
384
+
385
+ An `Amount` pairs a value with a `Kind`. Internally stored in standard units as `Rational`.
386
+
387
+ ```typescript
388
+ // Creation (a string, or `Rationalish` = number | bigint | Rational)
389
+ Amount.from(1.5, ETH); // uses human unit by default
390
+ Amount.from(1.5, ETH, "Gwei"); // explicit unit
391
+ Amount.from("1,000.5", ETH); // from string
392
+
393
+ // Parsing
394
+ Amount.parse("1.5 ETH", ETH);
395
+ Amount.parse("2 hours 30 minutes", Duration);
396
+ Amount.parse("2 1/2 hours", Duration); // fractions and mixed numbers
397
+ Amount.parse("1.5 ETH", ETH, USD, BTC); // any number of candidate kinds; the symbols pick one
398
+
399
+ // Unit conversion
400
+ amt.in("ETH"); // Rational
401
+ amt.in("atomic"); // bigint — floors; in("wei") is the same unit, exact
402
+ amt.in("human"); // Rational
403
+
404
+ // Rounding
405
+ amt.floorTo("Gwei"); amt.ceilTo("Gwei"); amt.roundTo("Gwei");
406
+ ```
407
+
408
+ Numbers and strings are read by `Rational.from` — see [Rational](#rational) for what a double means and which string forms are accepted. `Amount.parse` is narrower on the value side: a value token is digits with separators, a decimal point, a fraction or a mixed number, and never an exponent — `e` reads as the start of a symbol. That is the grammar `toString` emits. Everything downstream of construction is exact, with one deliberate exception: `in("atomic")` returns a `bigint`, so it floors — an atomic count can't be fractional. The same unit by name (`in("wei")`) returns the exact `Rational`; when flooring isn't the direction wanted, `ceilTo("atomic")`/`roundTo("atomic")` first.
409
+
410
+ `roundTo` (and `Rational.round`) round **half away from zero**: `0.5 → 1`, `-0.5 → -1`, so `round(-x) = -round(x)` — unlike JS's `Math.round`, which sends `-0.5 → 0` and thus breaks that symmetry. It's the only rounding mode; for anything else (e.g. banker's / half-to-even) compose it from `floorTo`/`ceilTo` or work from the exact `.in(unit)` value.
411
+
412
+ ```typescript
413
+ // Arithmetic (same kind required for add/sub)
414
+ a.add(b); a.sub(b); a.mul(2); a.div(2); a.mod(b);
415
+ a.abs(); a.neg();
416
+ a.ratio(b); // same kind ⇒ dimensionless Rational
417
+
418
+ // Comparison & queries
419
+ a.eq(b); a.ne(b); a.lt(b); a.le(b); a.gt(b); a.ge(b);
420
+ a.isZero(); a.sign(); // -1 | 0 | 1
421
+
422
+ // Aggregation & comparison — variadic free functions, like Math.min (Rates and Rationals work
423
+ // too). A possibly-empty collection spreads behind an explicit first operand — min/max have no
424
+ // identity element and an empty sum needs to know its kind. The result is of the kind every
425
+ // operand has: a union-kinded operand narrows to what the others admit, and two distinct kinds
426
+ // make the call `never` — it can only throw:
427
+ min(a, b, c); max(a, b); sum(a, b, c); sum(eth(0), ...fees);
428
+ clamp(x, lo, hi);
429
+ amounts.sort(compare); // three-way comparison: -1 | 0 | 1
430
+
431
+ // "an amount of my own kind" — the constructors of choice in kind-generic code (see below)
432
+ a.zero(); // 0 of a's kind
433
+ a.ofSame(2, "Gwei"); // 2 Gwei of a's kind
434
+
435
+ // Kind conversion via Rate (dimensional analysis)
436
+ ethAmount.mul(usdPerEth); // ETH × USD/ETH = USD
437
+ usdAmount.div(usdPerEth); // USD ÷ USD/ETH = ETH
438
+ ```
439
+
440
+ `allocate` splits an amount into parts that are each a whole number of some unit — atomic by default — yet still sum exactly, leftover units going to the largest fractional shares (the largest-remainder method; flooring each share independently would quietly leak dust):
441
+
442
+ ```typescript
443
+ allocate(usdc(1), 3); // [0.333334, 0.333333, 0.333333] USDC — sums exactly to 1
444
+ allocate(total, [4, 1]); // pro-rata by weights
445
+ allocate(total, [1, 1], "USDC"); // quantized to a specific unit
446
+ ```
447
+
448
+ The amount must itself be whole in the chosen unit — allocation never rounds the total, so quantization (and the dust decision that comes with it) stays with the caller. Literal counts and weight tuples return length-typed tuples, ready for destructuring.
449
+
450
+ #### Formatting
451
+
452
+ ```typescript
453
+ const amt = Amount.from("1234.56789", ETH);
454
+
455
+ amt.toString(); // "1,235 ETH" (approximate, default)
456
+ amt.toString("exact"); // "1,234.56789 ETH"
457
+ amt.toString("inUnit", "Gwei"); // "1,234,567,890,000 Gwei" (precision defaults to 0)
458
+ amt.toString("inUnit", "ETH", { precision: 2 }); // "1,234.57 ETH"
459
+
460
+ //trimZeros is true by default and trims trailing decimal zeros
461
+ amt.toString("inUnit", "ETH", { precision: 6, trimZeros: false }); // "1,234.567890 ETH"
462
+
463
+ // precision also takes any decimal unit:
464
+ amt.toString("inUnit", "ETH", { precision: "Gwei", trimZeros: false }); // "1,234.567890000 ETH"
465
+
466
+ // thousandsSep adds a separator for every group of 3 in integer range (options: "," | "_" | "")
467
+ amt.toString("approximate", { thousandsSep: "_" }); // "1_235 ETH"
468
+
469
+ // for multi-system kinds, the first positional arg can be a system name:
470
+ height.toString("imperial"); // "5 ft 10 in" (height from the Length example above)
471
+ ```
472
+
473
+ `thousandsSep` and `trimZeros` together are `ToFixedOptions`, the separator alone a `ThousandsSep`. A unit-valued `precision` accepts exactly `DecimalSymbolsOf<K>` — the kind's symbols that have an `oom` to count in.
474
+
475
+ Both modes render in the same unit and differ only in digits. The unit is the largest one the value reaches, promoted to the next one up when the ladder gap is six or more orders of magnitude and the promoted reading has at least a thousandth of it — so `0.5 ETH` is not `500,000,000 Gwei`, while `15,000 Gwei` stays put rather than becoming `0.000015 ETH`. A zero reaches no unit and renders in the `human` unit (the standard one when none is declared), or in the smallest unit of a rendered system that lacks it. `"approximate"` (the default) means short: three significant digits in the unit a value reaches, three decimals of a unit it was promoted to (`1,500,000 Gwei` reads `0.002 ETH`) or of the smallest unit it falls below, so anything under a thousandth of the smallest unit reads `0`. A reading that rounds up to the next unit renders in that one: `$0.9996` reads `$1`, not `100c`. `"exact"` shows every digit: the terminating decimal expansion when there is one, the mixed-number form otherwise — `"1/3 ETH"`, `"$1,763,668,414 3/7"` — so nothing is ever rounded away. Scale systems render across their units in both modes: `"approximate"` rounds to whole units of the smallest one, renders whole units down from the largest it reaches, and stops once what remains is under a thousandth of the whole (`70.08 in` is `"5 ft 10 in"`, `119.6 s` is `"2 minutes"`; a value below the smallest unit renders in it alone, to three decimals, `"0.5 seconds"`), `"exact"` runs down to the smallest unit and keeps its tail exact (`"1 minute 40 1/3 seconds"`).
476
+
477
+ `toJSON` (what `JSON.stringify` calls) is `toString("exact")` and always round-trips through `Amount.parse`. It takes no arguments of its own — `JSON.stringify` invokes `toJSON(key)` with the property key, so options go to `toString("exact", opts)`, which `Rate` accepts in the same spelling. `Rate.toJSON` quotes against the denominator's human unit when it declares one (`$/ETH`, `USD/BTC`), as `Rate.toString` does. A denominator without one is searched for the unit the rate reads best against: a numerator that terminates beats one in mixed-number form, then shorter output wins. A `Rate` keeps only a ratio and no memory of the units it was built from, so `usd(100).per(Amount.parse("1 day", Duration))` coming back as `"$100/day"` is the search at work — against the standard unit that same rate is `25/216` cents per second.
478
+
479
+ ### Rate
480
+
481
+ A `Rate` represents a ratio between two `Kind`s (e.g., a price): `Rate<USD, ETH>` reads "USD per ETH". The two must be distinct kinds — a ratio within one kind is dimensionless, which is what `Rational` is for — so `Rate.from` and `per` reject a matching pair.
482
+
483
+ ```typescript
484
+ // Creation
485
+ Rate.from(3000, USD, ETH); // 3000 USD per ETH
486
+ usd(3_000).per(ETH); // equivalent to ^
487
+ Rate.from(usdAmount, ethAmount); // from two amounts
488
+ usdAmount.per(ethAmount); // equivalent to ^
489
+ ```
490
+
491
+ The numeric forms read their value in **human units** — `Rate.from(3000, USD, ETH)` is 3000 human-USD per one human-ETH — which is why they require both kinds to declare a `human` unit. The amount forms carry their own units and have no such requirement.
492
+
493
+ ```typescript
494
+ // Parsing
495
+ Rate.parse("3000 USD/ETH", USD, ETH);
496
+ Rate.parse("1/2 BTC/ETH", BTC, ETH); // ratio syntax
497
+
498
+ // Get ratio in specific units
499
+ rate.in("USD", "ETH"); // Rational(3000)
500
+
501
+ // Round to integer multiples of a unit pair
502
+ rate.floorTo("USD", "ETH"); rate.ceilTo("USD", "ETH"); rate.roundTo("USD", "ETH");
503
+
504
+ // Arithmetic (same num/den kinds required for add/sub — e.g. benchmark + spread)
505
+ rate.add(spread); rate.sub(spread);
506
+ rate.mul(2); rate.div(2);
507
+ rate.abs(); rate.neg();
508
+
509
+ // Queries
510
+ rate.isZero(); rate.sign(); // -1 | 0 | 1
511
+
512
+ // Invert
513
+ rate.inv(); // now ETH / USD
514
+
515
+ // chaining
516
+ usdPerEth.combine(ethPerBtc); // USD/BTC
517
+ usdPerEth.cancel(ethPerUsd); // Rational — reciprocal dimensions cancel out
518
+
519
+ // Formatting (numerator per the numerator kind's display options, hence the prefixed "$")
520
+ rate.toString(); // "$3,000/ETH"
521
+ rate.toString({ numSymbol: "USD" }); // "3,000 USD/ETH"
522
+ rate.inv().toString(); // "0.000333 ETH/$" (3 significant digits)
523
+ ```
524
+
525
+ `combine` returns a `Rate` and throws if the outer kinds cancel; use `cancel` for reciprocal dimensions. `cancel` multiplies the ratios and checks both kind matches. Keeping the operations separate makes their return types reliable even when kinds are generic or unions.
526
+
527
+ ## Type Narrowing
528
+
529
+ Use type guards when working with unions:
530
+
531
+ ```typescript
532
+ if (Amount.isOfKind(amt, ETH))
533
+ amt.in("wei"); // narrowed
534
+
535
+ if (Rate.hasNum(rate, ETH)) //or hasDen
536
+ rate.in("Gwei", "$"); // narrowed numerator
537
+
538
+ if (Amount.allOfKind(amts, ETH)) // and Rate.allHaveNum / allHaveDen
539
+ sum(eth(0), ...amts); // Amount<typeof ETH>, not the union the elements were declared with
540
+ ```
541
+
542
+ The free functions `isAmount` and `isRate` tell the classes apart — from each other and from everything else. Across a union they keep exactly the matching constituents (an `Amount<K> | Rate<NK, DK> | Rational` narrows to `Amount<K>`), and an `unknown` narrows to `Amount<Kind>`/`Rate<Kind, Kind>`:
543
+
544
+ ```typescript
545
+ if (isAmount(value))
546
+ value.toString(); // narrowed
547
+ ```
548
+
549
+ ## Kind-Generic Code
550
+
551
+ Code that is generic over kinds works without casts: methods preserve K through chains and same-kind operands, helpers infer K from concrete and union arguments alike, and mixing two different type parameters in a same-kind operation is a compile error. A rate operand is the exception: at an open K its amount-side kind is checked at runtime only (see [Amount.md](https://github.com/Onrail/ts-sdk/blob/main/packages/amount/Amount.md)).
552
+
553
+ ```typescript
554
+ const afterFee = <K extends KindWithAtomic>(amount: Amount<K>, fee: Amount<typeof Percentage>) =>
555
+ amount.sub(amount.mul(fee)).floorTo("atomic"); //: Amount<K> — no casts anywhere
556
+ ```
557
+
558
+ Three things to know:
559
+
560
+ - `amt.kind` erases to the constraint at an open K (likewise `Rate`'s `num`/`den`). Use `zero()`/`ofSame()` — both classes carry them — for "one of my own kind(s)", or the free functions `kindOf(amt)` / `numKindOf(rate)` / `denKindOf(rate)` for exact reads. `inv`'s swapped result erases the same way; `invert(rate)` is its kind-exact spelling.
561
+ - Unit symbols are exactly as available as the constraint promises: `KindWithAtomic` admits `"atomic"` (and `"standard"`), nothing else. That set is `SymbolsOf<K>`; `KindUnitSymbols<K>` is the declared symbols without the meta ones, and `ResolvedSymbolOf<K, M>` is what a meta symbol stands for.
562
+ - Signatures take the `Amount`/`Rate` aliases; the `_Amount`/`_Rate` classes behind them are exported for type reachability and `instanceof`, never for annotations. `AmountFromArgs<K>` is `Amount.from`'s trailing parameter list, for wrapping it in a kind-generic factory of your own.
563
+
564
+ ## JSON Codecs
565
+
566
+ `jsonStringify`/`jsonParse` from `@onrail-xyz/utils` carry amounts, rates, and rationals once handed the matching codecs:
567
+
568
+ ```typescript
569
+ import { jsonStringify, jsonParse } from "@onrail-xyz/utils";
570
+ import { amountCodec, rateCodec, rationalCodec } from "@onrail-xyz/amount";
571
+
572
+ const codecs = [amountCodec([ETH, USD]), rateCodec(USD, ETH), rationalCodec];
573
+
574
+ const json = jsonStringify({ balance: eth(1).div(3) }, codecs);
575
+ // => '{"balance":{"$type":"Amount","value":{"kind":"ETH","value":"1/3 ETH"}}}'
576
+
577
+ jsonParse(json, codecs); // => the original, exactly
578
+ ```
579
+
580
+ The payload names its kind alongside the rendering, and a `Rate` names both of its own (`{ num, den, value }`). That name is what decoding resolves against the candidates — the rendering alone cannot identify a kind whenever two candidates own the same unit symbol, and `%` is not an unusual thing for two kinds to share. The value half is then parsed against the single kind the payload names, so a shared symbol is no longer ambiguous.
581
+
582
+ The kind-aware codecs take those candidates the way `Amount.parse` does, and `rationalCodec` needs none. `test` claims every instance regardless of kind, so **encoding is total** while decoding a kind that is not among the candidates throws `No candidate kind named "ETH"` — serializing outside the candidate set fails on the way back in, loudly, rather than silently handing back a wire object.
583
+
584
+ ## Limitations
585
+
586
+ ### Symbol Characters
587
+
588
+ Symbols cannot be empty, cannot start with a minus sign or a digit, and cannot contain:
589
+ - Whitespace
590
+ - Commas, underscores, dots, or slashes (used in number parsing)
591
+ - Digits at all, if the symbol is prefixed: a value follows it directly (`$100`)
592
+
593
+ A postfix symbol may hold digits past its first character (`USDT0`, `C98`), since it ends at the next space.
594
+
595
+ `kind()` rejects offenders — a symbol containing any of these could never round-trip through its own kind's parsing.
596
+
597
+ The names `"standard"`, `"human"` and `"atomic"` are reserved too — they address units generically, so `kind()` rejects a unit trying to claim one. Likewise for systems: `toString` takes a system name where it takes a display mode, so `"approximate"`, `"exact"` and `"inUnit"` cannot name a system.
598
+
599
+ Unicode symbols work fine: `$`, `€`, `¥`, `m³`, `µs`, `°C`. Symbols are identifiers, so parsing compares them by their NFKC form, as Unicode prescribes for identifiers: `"3 m3"` parses under a kind that spells the unit `m³`, `"20 ℃"` under `°C`, and `"5 μs"` with a Greek mu (U+03BC) under `µs` with a micro sign (U+00B5) — two code points that render alike. Output always uses the spelling the kind declares, so declare the typographic form (`m³`) and let input be lax — except for a prefix symbol's digits, which input can't separate from the value that follows (`m3100`). Since equivalent spellings are one symbol, `kind()` rejects a kind that declares two of them.
600
+
601
+ ### Locale
602
+
603
+ Numbers parse and print en-US only — `.` as the decimal point, `,`/`_` as thousands separators (the exact rules are under [Rational](#rational)) — and there is no `Intl` hook to change that. Symbol placement is the kind's business (`"$100"`, `"100 USD"`), the digits are not.
604
+
605
+ ### Ratios, Not Dimensional Algebra
606
+
607
+ A `Rate` is one kind over one kind: no products, no exponents, no nesting, so quantities like `m/s²` or `N·m` have no spelling here. `combine` chains ratios (USD/ETH × ETH/BTC = USD/BTC) and that is the whole of the composition.
608
+
609
+ ### Linear Unit Systems Only
610
+
611
+ Kinds like temperature where different systems use affine rather than just linear transforms (i.e. they have an additive component e.g. `x °C = 9/5 x + 32 °F`) are not supported (adding support wouldn't be too hard, but the additional complexity is likely not worth it).
612
+
613
+ ### Performance
614
+
615
+ Exact arithmetic on `bigint` fractions, measured on ordinary dev hardware: the arithmetic core (`add`, `mul`, comparisons, `in`, the rounding trio) runs at 50–130 ns per operation on ladder-sized values, construction around 0.5 µs, parsing and formatting at 0.7–2 µs, and summing ten thousand amounts takes under a millisecond. For anything short of a hot inner loop, exactness is effectively free.
616
+
617
+ The one pattern that isn't: **composing non-terminating fractions**. Nothing is ever rounded, so compounding a daily rate like `7301/7300` keeps every digit — ten thousand steps grow the denominator to ~128,000 bits and cost ~110 ms in total, each step pricier than the last (a run's cost grows quadratically with its length). Where a computation re-quantizes anyway, `roundTo("atomic")` per step collapses the fraction back to ladder size: the same ten thousand steps then take ~11 ms, stay flat, and display the same value. Being exact costs ~100 ns; carrying every digit of 27 years of daily compounding is a choice.
@@ -0,0 +1,31 @@
1
+ import type { RoArray, RoTuple, HeadTail } from "@onrail-xyz/utils";
2
+ import { type Rationalish, Rational } from "./rational.js";
3
+ import type { Kind } from "./kind.js";
4
+ import { type Amount } from "./amount.js";
5
+ import { type Rate, _Rate } from "./rate.js";
6
+ export type CommonKind<Ks extends RoArray<Kind>> = Ks extends RoTuple ? Ks extends HeadTail<Ks, infer H, infer T> ? H & CommonKind<T> : unknown : unknown;
7
+ type AmountsOf<Ks extends RoArray<Kind>> = {
8
+ readonly [I in keyof Ks]: Amount<Ks[I]>;
9
+ };
10
+ type SideOf<R, S extends 0 | 1> = R extends _Rate<infer NK, infer DK> ? [NK, DK][S] : never;
11
+ type SidesOf<Rs extends RoArray, S extends 0 | 1> = {
12
+ readonly [I in keyof Rs]: SideOf<Rs[I], S>;
13
+ };
14
+ type CommonRate<NK extends Kind, DK extends Kind, Rs extends RoArray> = Rate<NK & CommonKind<SidesOf<Rs, 0>>, DK & CommonKind<SidesOf<Rs, 1>>>;
15
+ export declare function min<K extends Kind, Ks extends RoArray<Kind>>(first: Amount<K>, ...rest: AmountsOf<Ks>): Amount<K & CommonKind<Ks>>;
16
+ export declare function min<NK extends Kind, DK extends Kind, Rs extends RoArray<Rate<Kind, Kind>>>(first: Rate<NK, DK>, ...rest: Rs): CommonRate<NK, DK, Rs>;
17
+ export declare function min(first: Rationalish, ...rest: RoArray<Rationalish>): Rational;
18
+ export declare function max<K extends Kind, Ks extends RoArray<Kind>>(first: Amount<K>, ...rest: AmountsOf<Ks>): Amount<K & CommonKind<Ks>>;
19
+ export declare function max<NK extends Kind, DK extends Kind, Rs extends RoArray<Rate<Kind, Kind>>>(first: Rate<NK, DK>, ...rest: Rs): CommonRate<NK, DK, Rs>;
20
+ export declare function max(first: Rationalish, ...rest: RoArray<Rationalish>): Rational;
21
+ export declare function sum<K extends Kind, Ks extends RoArray<Kind>>(first: Amount<K>, ...rest: AmountsOf<Ks>): Amount<K & CommonKind<Ks>>;
22
+ export declare function sum<NK extends Kind, DK extends Kind, Rs extends RoArray<Rate<Kind, Kind>>>(first: Rate<NK, DK>, ...rest: Rs): CommonRate<NK, DK, Rs>;
23
+ export declare function sum(first: Rationalish, ...rest: RoArray<Rationalish>): Rational;
24
+ export declare function compare<K extends Kind>(a: Amount<K>, b: NoInfer<Amount<K>>): -1 | 0 | 1;
25
+ export declare function compare<NK extends Kind, DK extends Kind>(a: Rate<NK, DK>, b: NoInfer<Rate<NK, DK>>): -1 | 0 | 1;
26
+ export declare function compare(a: Rationalish, b: Rationalish): -1 | 0 | 1;
27
+ export declare function clamp<K extends Kind, KL extends Kind, KH extends Kind>(x: Amount<K>, lo: Amount<KL>, hi: Amount<KH>): Amount<K & KL & KH>;
28
+ export declare function clamp<NK extends Kind, DK extends Kind, NL extends Kind, DL extends Kind, NH extends Kind, DH extends Kind>(x: Rate<NK, DK>, lo: Rate<NL, DL>, hi: Rate<NH, DH>): Rate<NK & NL & NH, DK & DL & DH>;
29
+ export declare function clamp(x: Rationalish, lo: Rationalish, hi: Rationalish): Rational;
30
+ export {};
31
+ //# sourceMappingURL=aggregating.d.ts.map