# `@suaveplan/i18n-formatter-number` — `Intl.NumberFormat` wrapper **Package**: `@suaveplan/i18n-formatter-number` (NEW) **Status**: PROPOSED **Type**: feature (new universal-tier package; verbatim port of `formatting/number/`) **Date**: 2026-05-05 **Wave**: 3 (depends on Wave 1 foundation; required by Wave 6 core slim and Wave 3 currency formatter sibling) **Design doc**: `openspec/cross-cutting-designs/2026-05-05-i18n-modular-split.md` (especially §5 formatter package shape, §5b FormatBridge, §7.1) **Path**: `packages/i18n/i18n-formatter-number/` **Tier**: universal ## Why The 14-package i18n modular split lifts each `Intl.*` wrapper out of the monolith into its own sibling package so consumers can pull only the formatters they actually use. `@suaveplan/i18n-formatter-number` is the canonical Wave-3 template: every other formatter package follows its shape (per-(locale, options-hash) cache, Zod-validated options, OTel histogram + cache-hit counter, helper + class twin surface). Today `formatting/number/` ships: - `NumberFormatter` class wrapping `Intl.NumberFormat` (`packages/i18n/i18n/src/formatting/number/number.ts` L89–L100) - `formatNumber` standalone helper (`number.ts` L67–L83) - `formatPercent`, `formatCompact`, `formatUnit` ergonomic helpers (`number-helpers.ts` L8–L48) - Zod option schema (`number.schemas.ts` L1–L41) - Per-(locale, options-hash) cache via `JSON.stringify` keying (`number.ts` L13–L20) - Structured error mapping: `INVALID_FORMATTER_OPTIONS` on Zod failure (`number.ts` L22–L31), `UNSUPPORTED_LOCALE` on `Intl.NumberFormat` `RangeError` (`number.ts` L33–L48) - OTel histogram `i18n.format.number.duration_ms` and `i18n.format.cache_hit{formatter="number"}` counter via `observability/format-metrics.ts` Splitting this into its own package is the lowest-risk Wave-3 step: the surface is small, the test suite is comprehensive (123-line `number.test.ts` + 123-line `number.spec-conformance.test.ts`), and downstream consumers (currency formatter; ICU `{value, number}` placeholder resolution via `FormatBridge`) need a stable target before Wave 6. ## What changes ### New package: `@suaveplan/i18n-formatter-number` - ADDED: `packages/i18n/i18n-formatter-number/` package scaffold (`package.json`, `tsconfig.json`, `vite.config.ts`, `README.md`). - ADDED: `src/formatter.ts` — `NumberFormatter` class + `formatNumber` standalone + `clearNumberFormatterCache` test-only helper. Verbatim port of monolith `number.ts` (L1–L106) with import rewires (`@suaveplan/i18n-shared` instead of `../../error-codes.js` / `../../errors/i18n-error.js`; in-package `observability` module instead of `../../observability/format-metrics.js`). - ADDED: `src/formatter.types.ts` — `NumberFormatterStyle`, `NumberFormatterNotation`, `NumberFormatterCurrencyDisplay`, `NumberFormatterSignDisplay`, `NumberFormatterOptions`, `NumberFormatterCacheKey`. Verbatim port of monolith `number.types.ts` (L1–L40). - ADDED: `src/formatter.schemas.ts` — `NumberFormatterOptionsSchema` Zod schema. Verbatim port of monolith `number.schemas.ts` (L1–L41). - ADDED: `src/helpers.ts` — `formatPercent`, `formatCompact`, `formatUnit`. Verbatim port of monolith `number-helpers.ts` (L1–L48). - ADDED: `src/observability.ts` — local OTel histogram + counter emitters (`recordNumberFormat`, `recordCacheHit`). Sourced from monolith `observability/format-metrics.ts` (number-only slice). - ADDED: `src/formatter.test.ts` — verbatim port of monolith `number.test.ts` (123 lines) with runner imports through `@suaveplan/testing/runner`. - ADDED: `src/formatter.spec-conformance.test.ts` — verbatim port of monolith `number.spec-conformance.test.ts` (123 lines). - ADDED: `src/helpers.test.ts` — verbatim port of monolith `number-helpers.test.ts` (41 lines). - ADDED: `src/index.ts` — explicit named re-exports (no `export *` per CLAUDE.md §10). - ADDED: `src/formatter.md`, `src/helpers.md` — sibling docs. ### Subpath exports - `.` — root barrel (`NumberFormatter`, `formatNumber`, `formatPercent`, `formatCompact`, `formatUnit`, types, `NumberFormatterOptionsSchema`) - `./formatter` — `NumberFormatter` class + `formatNumber` + types + schema only - `./helpers` — `formatPercent`, `formatCompact`, `formatUnit` only ### Dependencies - `@suaveplan/i18n-shared workspace:^` — `I18N_CODES`, `I18nError` - `@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/number/` from `packages/i18n/i18n/src/`** — happens in Wave 6 (`i18n-core-slim`). For Waves 3–5, both copies coexist and both pass their tests. - **Currency-style support** — handled by sibling package `@suaveplan/i18n-formatter-currency`, which depends on this package. - **Switching to a different number-format engine** (e.g. `globalize` or CLDR via `cldrjs`) — explicit non-goal; `Intl.NumberFormat` is the cross-runtime baseline. - **Adding `format-to-parts` / `format-range` / `format-range-to-parts`** — follow-up; current monolith does not expose them. ## Source mapping | Requirement | Source file | Source lines | Notes | |---|---|---|---| | `NumberFormatter` class | `packages/i18n/i18n/src/formatting/number/number.ts` | L89–L100 | Verbatim port; rewire imports | | `formatNumber` standalone | `packages/i18n/i18n/src/formatting/number/number.ts` | L67–L83 | Verbatim port; rewire imports | | `clearNumberFormatterCache` | `packages/i18n/i18n/src/formatting/number/number.ts` | L102–L105 | Verbatim port | | Per-(locale, options-hash) cache | `packages/i18n/i18n/src/formatting/number/number.ts` | L13–L20, L50–L60 | Verbatim port | | `INVALID_FORMATTER_OPTIONS` mapping | `packages/i18n/i18n/src/formatting/number/number.ts` | L22–L31 | Verbatim port; throws `I18nError` from `@suaveplan/i18n-shared` | | `UNSUPPORTED_LOCALE` mapping | `packages/i18n/i18n/src/formatting/number/number.ts` | L33–L48 | Verbatim port | | Type definitions | `packages/i18n/i18n/src/formatting/number/number.types.ts` | L1–L40 | Verbatim port | | Zod schema | `packages/i18n/i18n/src/formatting/number/number.schemas.ts` | L1–L41 | Verbatim port | | `formatPercent`, `formatCompact`, `formatUnit` helpers | `packages/i18n/i18n/src/formatting/number/number-helpers.ts` | L1–L48 | Verbatim port | | OTel histogram + cache-hit counter | `packages/i18n/i18n/src/observability/format-metrics.ts` | number-only slice | Local re-implementation in `src/observability.ts` | ### Requirements with no source line | Requirement | Justification | |---|---| | Subpath export `./formatter` | Tree-shaking — slim-core `FormatBridge` wiring imports only the standalone `formatNumber`, not the `formatPercent` / `formatCompact` / `formatUnit` helpers | | Subpath export `./helpers` | Symmetric — consumers wanting only the percent/compact/unit shortcuts can avoid loading the class wrapper | | `FORMATTER_CACHE_OVERFLOW` (declared in `@suaveplan/i18n-shared`) | The current monolith's cache is unbounded; the per-package split is the right time to bound it. Implementation may emit this code if and when a `maxCacheEntries` option is added (follow-up). For Wave 3, the code is imported but only thrown from the shared registry — this package exercises declaration but not enforcement. | ## Verification approach Each requirement carries a `**Verified by:**` line in the spec delta. Concrete test files: - `packages/i18n/i18n-formatter-number/src/formatter.test.ts` — every option permutation (`style`, `notation`, `currency`, `currencyDisplay`, `signDisplay`, `useGrouping`, `compactDisplay`, fraction-digit caps); class wrapper twins helper output; `RangeError` from `Intl.NumberFormat` mapped to `UNSUPPORTED_LOCALE`; Zod failure mapped to `INVALID_FORMATTER_OPTIONS` - `packages/i18n/i18n-formatter-number/src/formatter.spec-conformance.test.ts` — verbatim port of monolith spec-conformance suite (asserts every code path documented in `openspec/specs/i18n/spec.md`'s number-formatter requirements still passes) - `packages/i18n/i18n-formatter-number/src/helpers.test.ts` — `formatPercent` defaults to no fraction digits; `formatCompact` produces `"1.2K"` / `"1.2M"`; `formatUnit` accepts unit identifiers - `packages/i18n/i18n-formatter-number/src/observability.test.ts` — histogram observed on first call; counter incremented only on second call with same `(locale, options)`; both metric names match the design-doc-mandated identifiers - `packages/i18n/i18n-formatter-number/src/index.test.ts` — every documented public export resolves through the root barrel; every documented subpath resolves ## Impact - New universal-tier package — joins `@suaveplan/i18n-shared` and `@suaveplan/i18n-icu-parser` under `packages/i18n/`. - Direct consumer in Wave 3: `@suaveplan/i18n-formatter-currency` (currency wraps `Intl.NumberFormat` with `style: "currency"`; depends on this package's helper for delegation). - Indirect consumer in Wave 6: `@suaveplan/i18n` slim core wires `formatNumber` into the default `FormatBridge` for ICU `{value, number}` placeholder rendering. - No downstream consumer changes needed — the slim core re-export bridge surfaces every public symbol from this package under `@suaveplan/i18n`. - `MANIFEST.md` and `CLAUDE.md` updated post-merge.