9.2 KiB
@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:
NumberFormatterclass wrappingIntl.NumberFormat(packages/i18n/i18n/src/formatting/number/number.tsL89–L100)formatNumberstandalone helper (number.tsL67–L83)formatPercent,formatCompact,formatUnitergonomic helpers (number-helpers.tsL8–L48)- Zod option schema (
number.schemas.tsL1–L41) - Per-(locale, options-hash) cache via
JSON.stringifykeying (number.tsL13–L20) - Structured error mapping:
INVALID_FORMATTER_OPTIONSon Zod failure (number.tsL22–L31),UNSUPPORTED_LOCALEonIntl.NumberFormatRangeError(number.tsL33–L48) - OTel histogram
i18n.format.number.duration_msandi18n.format.cache_hit{formatter="number"}counter viaobservability/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—NumberFormatterclass +formatNumberstandalone +clearNumberFormatterCachetest-only helper. Verbatim port of monolithnumber.ts(L1–L106) with import rewires (@suaveplan/i18n-sharedinstead of../../error-codes.js/../../errors/i18n-error.js; in-packageobservabilitymodule instead of../../observability/format-metrics.js). - ADDED:
src/formatter.types.ts—NumberFormatterStyle,NumberFormatterNotation,NumberFormatterCurrencyDisplay,NumberFormatterSignDisplay,NumberFormatterOptions,NumberFormatterCacheKey. Verbatim port of monolithnumber.types.ts(L1–L40). - ADDED:
src/formatter.schemas.ts—NumberFormatterOptionsSchemaZod schema. Verbatim port of monolithnumber.schemas.ts(L1–L41). - ADDED:
src/helpers.ts—formatPercent,formatCompact,formatUnit. Verbatim port of monolithnumber-helpers.ts(L1–L48). - ADDED:
src/observability.ts— local OTel histogram + counter emitters (recordNumberFormat,recordCacheHit). Sourced from monolithobservability/format-metrics.ts(number-only slice). - ADDED:
src/formatter.test.ts— verbatim port of monolithnumber.test.ts(123 lines) with runner imports through@suaveplan/testing/runner. - ADDED:
src/formatter.spec-conformance.test.ts— verbatim port of monolithnumber.spec-conformance.test.ts(123 lines). - ADDED:
src/helpers.test.ts— verbatim port of monolithnumber-helpers.test.ts(41 lines). - ADDED:
src/index.ts— explicit named re-exports (noexport *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—NumberFormatterclass +formatNumber+ types + schema only./helpers—formatPercent,formatCompact,formatUnitonly
Dependencies
@suaveplan/i18n-shared workspace:^—I18N_CODES,I18nError@suaveplan/types workspace:^@suaveplan/error workspace:^@opentelemetry/api catalog:observabilitytslib catalog:buildzod catalog:forms- No dep on any other formatter package, on
@suaveplan/i18ncore, or on any backend package.
Out of scope
- Removing
formatting/number/frompackages/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.
globalizeor CLDR viacldrjs) — explicit non-goal;Intl.NumberFormatis 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;RangeErrorfromIntl.NumberFormatmapped toUNSUPPORTED_LOCALE; Zod failure mapped toINVALID_FORMATTER_OPTIONSpackages/i18n/i18n-formatter-number/src/formatter.spec-conformance.test.ts— verbatim port of monolith spec-conformance suite (asserts every code path documented inopenspec/specs/i18n/spec.md's number-formatter requirements still passes)packages/i18n/i18n-formatter-number/src/helpers.test.ts—formatPercentdefaults to no fraction digits;formatCompactproduces"1.2K"/"1.2M";formatUnitaccepts unit identifierspackages/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 identifierspackages/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-sharedand@suaveplan/i18n-icu-parserunderpackages/i18n/. - Direct consumer in Wave 3:
@suaveplan/i18n-formatter-currency(currency wrapsIntl.NumberFormatwithstyle: "currency"; depends on this package's helper for delegation). - Indirect consumer in Wave 6:
@suaveplan/i18nslim core wiresformatNumberinto the defaultFormatBridgefor 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.mdandCLAUDE.mdupdated post-merge.