Files
2026-05-27 23:29:53 +01:00

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 Money class holding amount: bigint (minor units)
    • currency: CurrencyCode (ISO 4217 or registered custom code). Every operation returns a new instance — never mutates.
  • Currency metadata: a module-level Map of CurrencyInfo records (built-in 30 fiats + 5 crypto + caller-registered customs). getCurrency(code) is O(1); registerCurrency(info) mutates the module map (this matches upstream behaviour and is deliberately preserved for source-level compatibility).
  • Formatting: pure functions over MoneyLike. formatMoney uses Intl.NumberFormat to derive locale-aware decimal + thousands separators, then assembles the output by hand to preserve bigint precision (the formatter never loses precision via a Number cast). formatMoneyCompact is the one place where lossy Number conversion is acceptable (the K/M/B output is approximate by design).
  • Allocation: integer-arithmetic allocators that distribute remainders round-robin so the sum of shares always equals the input amount. The result type carries remainder: 0n as a contract-level invariant (the remainder field exists for future allocators that may surface it; today it is always zero).
  • Aggregation: sumMoney/minMoney/maxMoney that raise MoneyError({ 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 bare fs/path/stream/net/http/crypto imports
  • 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.json declares "sideEffects": false, no "browser" field, no engines pins.

What changes vs upstream

  1. throw new Error(...) replaced with throw new MoneyError(MONEY_CODES.<CODE>, ...) at every raise site (8 raise sites total — counted in upstream).
  2. 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.
  3. Tests rewritten to import from @suaveplan/testing/runner, gain { timeout: 5000 } on every it(...), and use expect(() => ...).toThrow(MoneyError) + code-asserting helpers.
  4. Sibling .md written for every implementation file (≥200 words, basic + advanced examples, full API reference, implementation notes).
  5. Six subpath exports vs upstream's three.
  6. Package version pinned to 0.1.0.
  7. Money.fromMajor(string) now parses through a hardened parseDecimalString that raises MoneyError({ code: MONEY.PARSE_ERROR }) on malformed input (non-numeric whole, non-numeric fraction, multiple decimal points). Upstream silently produced NaN-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 Money class is still implements MoneyLike so structural-typing consumers continue to accept it.
  • The currency-registry module-level Map mutation semantics — registerCurrency(info) is still global, still observable by every other call site.
  • formatMoneyCompact still lossy via Number (documented).
  • AllocationResult.remainder is still always 0n (allocators distribute remainders).