Files
suaveplan/openspec/archive/2026-05-09-i18n-formatter-currency/proposal.md
2026-05-27 23:29:53 +01:00

10 KiB
Raw Blame History

@suaveplan/i18n-formatter-currency — Intl.NumberFormat (currency style) wrapper + ISO 4217 metadata

Package: @suaveplan/i18n-formatter-currency (NEW) Status: PROPOSED Type: feature (new universal-tier package; verbatim port of formatting/currency/ plus class wrapper + Zod schema + observability uplift) Date: 2026-05-05 Wave: 3 (depends on Wave 1 foundation AND on @suaveplan/i18n-formatter-number; required by Wave 6 core slim) Design doc: openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md (especially §5 formatter package shape, §3 — currency is the only formatter that depends on a sibling formatter) Path: packages/i18n/i18n-formatter-currency/ Tier: universal

Why

The 14-package i18n modular split lifts each Intl.* wrapper out of the monolith into its own sibling package. @suaveplan/i18n-formatter-currency is the only Wave-3 formatter that depends on another formatter — @suaveplan/i18n-formatter-number — because Intl.NumberFormat with style: "currency" is the canonical currency-formatting API. Rather than duplicate the per-(locale, options-hash) cache + Zod surface from i18n-formatter-number, this package delegates the underlying Intl.NumberFormat construction to its sibling and adds a thin currency-specific layer on top:

  • ISO 4217 currency-code validation (length 3, uppercase letters)
  • Currency-symbol / currency-name / currency-info metadata extraction via formatToParts
  • A display option ("symbol" | "code" | "name" | "narrowSymbol") mapped to currencyDisplay

Today formatting/currency/ ships:

  • formatCurrency(amount, currency, locale, options?) standalone (packages/i18n/i18n/src/formatting/currency/currency.ts L16–L29)
  • formatCurrencyValue(amount, currency, locale) standalone — returns numeric portion only (currency.ts L35–L49)
  • getCurrencySymbol(currency, locale, display?) (currency.ts L55–L67)
  • getCurrencyName(currency, locale) (currency.ts L72–L80)
  • getCurrencyInfo(currency, locale) returning {code, symbol, name, decimalDigits} (currency.ts L85–L98)
  • BCP-47 normalisation via LOCALE_BCP47_MAP (currency.ts L4–L10)
  • CommonCurrency, CurrencyFormatOptions, CurrencyInfo type aliases (currency.types.ts L1–L34)

The split adds the CurrencyFormatter class wrapper, Zod-validated options (including ISO 4217 code validation), per-(locale, currency, options-hash) cache, and OTel histogram + cache-hit counter for parity with the rest of the family.

What changes

New package: @suaveplan/i18n-formatter-currency

  • ADDED: packages/i18n/i18n-formatter-currency/ package scaffold (package.json, tsconfig.json, vite.config.ts, README.md).
  • ADDED: src/formatter.ts — CurrencyFormatter class (NEW, holds stable currency + options); formatCurrency standalone (port of monolith L16–L29) extended with Zod validation, cache, and OTel emission; formatCurrencyValue (port of L35–L49); clearCurrencyFormatterCache test-only helper. Per-(locale, currency, options-hash) cache key ${locale}|${currency}|${JSON.stringify(options)}. Currency-style construction delegates to a thin Intl.NumberFormat wrapper (@suaveplan/i18n-formatter-number exposes the same Zod option-validation pattern this package mirrors for currency-specific options).
  • ADDED: src/formatter.types.ts — CommonCurrency (ISO 4217 alias union), CurrencyDisplay ("symbol" | "code" | "name" | "narrowSymbol"), CurrencyFormatterOptions, CurrencyInfo, CurrencyFormatterCacheKey. Sourced from monolith currency.types.ts L1–L34.
  • ADDED: src/formatter.schemas.ts — CurrencyCodeSchema Zod schema validating ISO 4217 (/^[A-Z]{3}$/); CurrencyFormatterOptionsSchema validating display, minimumFractionDigits, maximumFractionDigits, locale. NEW.
  • ADDED: src/helpers.ts — getCurrencySymbol(currency, locale, display?) (port of monolith L55–L67); getCurrencyName(currency, locale) (port of L72–L80); getCurrencyInfo(currency, locale) (port of L85–L98).
  • ADDED: src/observability.ts — local OTel histogram i18n.format.currency.duration_ms + counter i18n.format.cache_hit{formatter="currency"}.
  • ADDED: src/formatter.test.ts — port of monolith currency.test.ts (141 lines) plus new tests for CurrencyFormatter class, ISO 4217 validation, cache reuse.
  • ADDED: src/formatter.spec-conformance.test.ts — every requirement scenario in this delta.
  • ADDED: src/helpers.test.ts — covers getCurrencySymbol, getCurrencyName, getCurrencyInfo.
  • ADDED: src/index.ts — explicit named re-exports.
  • ADDED: src/formatter.md, src/helpers.md, src/observability.md — sibling docs.

Subpath exports

  • . — root barrel
  • ./formatter — CurrencyFormatter class + formatCurrency + formatCurrencyValue + types + schema only
  • ./helpers — getCurrencySymbol, getCurrencyName, getCurrencyInfo only

Dependencies

  • @suaveplan/i18n-shared workspace:^ — I18N_CODES, I18nError, LOCALE_BCP47_MAP
  • @suaveplan/i18n-formatter-number workspace:^ — currency formatting is Intl.NumberFormat({ style: "currency", ... }); this package mirrors the Zod-validation + cache + OTel pattern from its sibling so consumers get a uniform surface
  • @suaveplan/types workspace:^
  • @suaveplan/error workspace:^
  • @opentelemetry/api catalog:observability
  • tslib catalog:build
  • zod catalog:forms
  • No dep on any other formatter package, on @suaveplan/i18n core, or on any backend package.

Out of scope

  • Removing formatting/currency/ from packages/i18n/i18n/src/ — happens in Wave 6.
  • Custom ISO 4217 metadata table beyond what Intl.NumberFormat supports — out of scope; runtime ICU handles every active ISO 4217 code.
  • Cryptocurrency formatting (e.g. BTC, ETH not in ISO 4217) — out of scope; falls outside Intl.NumberFormat's currency table. The separate @suaveplan/cryptocurrency package owns crypto formatting.
  • Currency-conversion arithmetic — out of scope; a separate @suaveplan/currency package owns conversion.

Source mapping

Requirement Source file Source lines Notes
formatCurrency standalone packages/i18n/i18n/src/formatting/currency/currency.ts L16–L29 Verbatim port; extended with Zod validation + cache + OTel emission
formatCurrencyValue standalone packages/i18n/i18n/src/formatting/currency/currency.ts L35–L49 Verbatim port
getCurrencySymbol packages/i18n/i18n/src/formatting/currency/currency.ts L55–L67 Verbatim port
getCurrencyName packages/i18n/i18n/src/formatting/currency/currency.ts L72–L80 Verbatim port
getCurrencyInfo packages/i18n/i18n/src/formatting/currency/currency.ts L85–L98 Verbatim port
toBcp47 BCP-47 normalisation packages/i18n/i18n/src/formatting/currency/currency.ts L4–L10 Verbatim port; use LOCALE_BCP47_MAP from @suaveplan/i18n-shared
CommonCurrency, CurrencyFormatOptions, CurrencyInfo types packages/i18n/i18n/src/formatting/currency/currency.types.ts L1–L34 Verbatim port; rename CurrencyFormatOptions → CurrencyFormatterOptions for symmetry

Requirements with no source line

Requirement Justification
CurrencyFormatter class wrapper New — symmetric with NumberFormatter, RelativeTimeFormatter, ListFormatter, DisplayNameFormatter, DateFormatter
ISO 4217 CurrencyCodeSchema New — current monolith does not validate that the currency string matches ^[A-Z]{3}$; bogus codes today silently produce Intl.NumberFormat errors that surface as raw RangeError instead of structured INVALID_FORMATTER_OPTIONS
CurrencyFormatterOptionsSchema Zod schema New — current monolith does not validate options
INVALID_FORMATTER_OPTIONS mapping Bound to the new Zod schemas
Per-(locale, currency, options-hash) cache New — current monolith constructs a fresh Intl.NumberFormat per call; Wave-3 formatter shape mandates the cache for parity
OTel histogram i18n.format.currency.duration_ms + cache-hit counter New — canonical Wave-3 formatter shape mandates these
Dep on @suaveplan/i18n-formatter-number Currency is Intl.NumberFormat with style: "currency"; the design doc §3 explicitly lists this dep so the cache + Zod patterns can be shared rather than duplicated

Verification approach

  • packages/i18n/i18n-formatter-currency/src/formatter.test.ts — port of monolith currency.test.ts; cover USD / GBP / EUR / JPY (zero decimal digits) / CHF; symbol / code / name / narrowSymbol display; min/max fraction digits; class wrapper twins helper output; ISO 4217 violation ("$", "abc", "USDT") → INVALID_FORMATTER_OPTIONS; bogus locale → UNSUPPORTED_LOCALE; cache reuse on identical (locale, currency, options)
  • packages/i18n/i18n-formatter-currency/src/formatter.spec-conformance.test.ts — every requirement scenario runs as an executable test
  • packages/i18n/i18n-formatter-currency/src/helpers.test.ts — getCurrencySymbol("USD", "en-GB") returns "US$" (en-GB locale); getCurrencyName("USD", "en-GB") returns "US Dollar"; getCurrencyInfo("USD", "en-GB") returns {code: "USD", symbol, name, decimalDigits: 2}
  • packages/i18n/i18n-formatter-currency/src/observability.test.ts — histogram named i18n.format.currency.duration_ms; counter i18n.format.cache_hit{formatter="currency"} increments only on cache hit
  • packages/i18n/i18n-formatter-currency/src/index.test.ts — every documented public export resolves; subpaths resolve; dep on @suaveplan/i18n-formatter-number declared in package.json

Impact

  • New universal-tier package — joins the rest of the i18n family.
  • Only Wave-3 package with an intra-formatter dep (on @suaveplan/i18n-formatter-number).
  • Indirect consumer in Wave 6: @suaveplan/i18n slim core wires formatCurrency into the default FormatBridge for ICU {amount, number, currency=USD} placeholder rendering.
  • MANIFEST.md and CLAUDE.md updated post-merge.