8.4 KiB
Design — @suaveplan/money
Why this design
The upstream package is small, focused, and battle-tested. The right migration strategy is preserve the public surface verbatim and upgrade only where genesis policy demands it: typed errors, runner-neutral test imports, sibling docs, 100% coverage, subpath exports per feature module. The design below explains exactly which lines from upstream change and which stay the same.
Architecture
@suaveplan/money is a universal-tier leaf:
- Storage primitive: a single immutable
Moneyclass holdingamount: bigint(minor units)currency: CurrencyCode(ISO 4217 or registered custom code). Every operation returns a new instance — never mutates.
- Currency metadata: a module-level
MapofCurrencyInforecords (built-in 30 fiats + 5 crypto + caller-registered customs).getCurrency(code)isO(1);registerCurrency(info)mutates the module map (this matches upstream behaviour and is deliberately preserved for source-level compatibility). - Formatting: pure functions over
MoneyLike.formatMoneyusesIntl.NumberFormatto derive locale-aware decimal + thousands separators, then assembles the output by hand to preservebigintprecision (the formatter never loses precision via aNumbercast).formatMoneyCompactis the one place where lossyNumberconversion is acceptable (the K/M/B output is approximate by design). - Allocation: integer-arithmetic allocators that distribute remainders round-robin so the
sum of
sharesalways equals the inputamount. The result type carriesremainder: 0nas a contract-level invariant (the remainder field exists for future allocators that may surface it; today it is always zero). - Aggregation:
sumMoney/minMoney/maxMoneythat raiseMoneyError({ code: MONEY.CURRENCY_MISMATCH })on heterogeneous currency arrays.
File layout (genesis)
Drop the upstream core/ and formatting/ and allocation/ ancestor folders so every
implementation lives at <feature>/<feature>.ts per the no-prefix and feature-folder rules:
packages/finance/money/
├── package.json # 0.0.0, sideEffects:false, 6 subpath exports
├── README.md
├── tsconfig.json
├── tsconfig.lib.json
├── vite.config.ts
├── vitest.config.ts
├── tests/setup/polyfills.ts # empty — universal-tier
└── src/
├── index.ts # root barrel — explicit named re-exports
├── constants/
│ └── index.ts # currently empty (reserved)
├── error-codes/
│ ├── error-codes.ts # MONEY_CODES registry + MoneyError class
│ ├── error-codes.md
│ ├── error-codes.test.ts
│ └── index.ts
├── types/
│ ├── common.ts # MoneyLike, CurrencyCode, etc.
│ ├── common.md
│ └── index.ts
├── money/
│ ├── money.ts # Money class + applyRounding
│ ├── money.md
│ ├── money.test.ts
│ └── index.ts
├── currencies/
│ ├── currencies.ts # registry + lookup helpers + built-ins
│ ├── currencies.md
│ ├── currencies.test.ts
│ └── index.ts
├── formatter/
│ ├── formatter.ts # formatMoney + formatMoneyCompact
│ ├── formatter.md
│ ├── formatter.test.ts
│ └── index.ts
└── allocator/
├── allocator.ts # allocators + aggregators
├── allocator.md
├── allocator.test.ts
└── index.ts
The currencies.ts file is the only one approaching the 300-line hard limit. To stay under
budget while preserving the full built-in registry, the 35-entry table is inlined as a single
object literal (one line per entry, ~36 lines), with the lookup/registration helpers occupying
the remaining ~70 lines. The file is well under 300 lines.
Error taxonomy
MONEY_CODES = defineCodes("MONEY", [...]) lists exactly six stable codes:
| Code | Raised by |
|---|---|
CURRENCY_MISMATCH |
Money.add/subtract/compareTo cross-currency; sumMoney/minMoney/maxMoney heterogeneous input |
UNKNOWN_CURRENCY |
getCurrencyOrThrow(code) when neither built-in nor custom registers code |
DIVISION_BY_ZERO |
Money.divide(0) or Money.divide(0n) |
INVALID_ALLOCATION |
allocateEvenly(parts ≤ 0), allocateByRatios(empty array), allocateByRatios(negative ratio), allocateByRatios(totalRatio ≤ 0), allocateByPercentages(sum != 100 ± 0.001) |
EMPTY_INPUT |
sumMoney([]), minMoney(), maxMoney() |
PARSE_ERROR |
Reserved for parseDecimalString failures (currently every input parses; the code is wired into Money.fromMajor(string) and reachable via malformed input) |
MoneyError extends SuaveplanError<MoneyErrorCode> carries two optional discriminants for
diagnostics: currency?: CurrencyCode (the currency that triggered the error) and
otherCurrency?: CurrencyCode (for mismatch errors, the second currency in the pair). These are
exposed as instance properties so consumers can if (err instanceof MoneyError && err.code === "MONEY.CURRENCY_MISMATCH") and read err.currency / err.otherCurrency without parsing
strings.
Subpath exports
Every public module gets its own subpath export so consumers can tree-shake. The package
preserves a root "." export (everything) plus six feature-specific subpaths:
./money/money—Money,applyRounding./currencies/currencies— every registry function./formatter/formatter—formatMoney,formatMoneyCompact./allocator/allocator— allocators + aggregators./error-codes/error-codes—MONEY_CODES,MoneyError, type exports./types/common— all type exports
This is a strict superset of upstream's three subpaths (./core/money, ./formatting/formatter,
./allocation/allocator). Upstream consumers importing those paths will need to update — but
upstream had no genesis consumers when this change opened.
Test runner imports
Every test file imports from @suaveplan/testing/runner (rule 11). Upstream imported from
vitest directly; the migration commit rewrites every line.
Tier purity
The package is universal-tier:
- No
node:*,bun:*, or barefs/path/stream/net/http/cryptoimports - Only platform-aware call is
Intl.NumberFormat(locale).formatToParts(...)in the formatter, which is available in every supported runtime (Node ≥ 18, Bun, browsers, Web Workers). package.jsondeclares"sideEffects": false, no"browser"field, noenginespins.
What changes vs upstream
throw new Error(...)replaced withthrow new MoneyError(MONEY_CODES.<CODE>, ...)at every raise site (8 raise sites total — counted in upstream).- Folder layout flattened:
core/money.ts→money/money.ts,core/currencies.ts→currencies/currencies.ts,formatting/formatter.ts→formatter/formatter.ts,allocation/allocator.ts→allocator/allocator.ts,types/money.types.ts→types/common.ts. - Tests rewritten to import from
@suaveplan/testing/runner, gain{ timeout: 5000 }on everyit(...), and useexpect(() => ...).toThrow(MoneyError)+ code-asserting helpers. - Sibling
.mdwritten for every implementation file (≥200 words, basic + advanced examples, full API reference, implementation notes). - Six subpath exports vs upstream's three.
- Package version pinned to
0.1.0. Money.fromMajor(string)now parses through a hardenedparseDecimalStringthat raisesMoneyError({ code: MONEY.PARSE_ERROR })on malformed input (non-numeric whole, non-numeric fraction, multiple decimal points). Upstream silently producedNaN-ish bigints on bad input; the new path errors clearly.
What stays the same
- Every public symbol name, parameter order, return shape, option key, and default value.
- The
Moneyclass is stillimplements MoneyLikeso structural-typing consumers continue to accept it. - The currency-registry module-level
Mapmutation semantics —registerCurrency(info)is still global, still observable by every other call site. formatMoneyCompactstill lossy viaNumber(documented).AllocationResult.remainderis still always0n(allocators distribute remainders).