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

9.2 KiB
Raw Permalink Blame History

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