# `@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.