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

8.4 KiB

Capability Spec: i18n-formatter-number (i18n-formatter-number)

Package: @suaveplan/i18n-formatter-number Status: PROPOSED — first delta; becomes canonical spec on archive.

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

ADDED Requirements

Requirement: NumberFormatter class wrapping Intl.NumberFormat

The package SHALL export a NumberFormatter class that wraps Intl.NumberFormat with construction-time Zod validation of options and a format(value: number, locale: string): string method. Construction with malformed options SHALL throw I18nError(I18N.INVALID_FORMATTER_OPTIONS).

Scenario: format with default options

  • GIVEN const f = new NumberFormatter()
  • WHEN f.format(1234.5, "en-GB") is called
  • THEN the result equals "1,234.5"

Scenario: invalid options throw INVALID_FORMATTER_OPTIONS

  • WHEN new NumberFormatter({ style: "invalid" as never }) is called
  • THEN the call throws I18nError with code "I18N.INVALID_FORMATTER_OPTIONS"

Verified by: packages/i18n/i18n-formatter-number/src/formatter.test.ts::"NumberFormatter formats with default options" AND packages/i18n/i18n-formatter-number/src/formatter.test.ts::"NumberFormatter rejects invalid options with INVALID_FORMATTER_OPTIONS"


Requirement: formatNumber standalone helper

The package SHALL export a formatNumber(value, locale, options?) standalone helper that delivers the same behaviour as NumberFormatter.format without requiring class construction. The helper SHALL validate options with the same Zod schema and SHALL throw the same error codes.

Scenario: standalone helper formats with options

  • WHEN formatNumber(0.5, "en-GB", { style: "percent" }) is called
  • THEN the result equals "50%"

Scenario: standalone helper twins class output

  • WHEN both formatNumber(v, l, opts) and new NumberFormatter(opts).format(v, l) are called for the same (v, l, opts)
  • THEN the results are identical

Verified by: packages/i18n/i18n-formatter-number/src/formatter.test.ts::"formatNumber formats percent style" AND packages/i18n/i18n-formatter-number/src/formatter.test.ts::"formatNumber output twins NumberFormatter.format output"


Requirement: Intl.NumberFormat RangeError maps to UNSUPPORTED_LOCALE

The package SHALL catch every RangeError thrown by the Intl.NumberFormat constructor (caused by an unsupported locale or unsupported option value) and re-throw it as I18nError(I18N.UNSUPPORTED_LOCALE) with the original error attached as cause and the locale string in context.

Scenario: bogus locale throws UNSUPPORTED_LOCALE

  • WHEN formatNumber(1, "xx-bogus-tag-!!!") is called
  • THEN the call throws I18nError with code "I18N.UNSUPPORTED_LOCALE" AND the error's cause is the original RangeError

Verified by: packages/i18n/i18n-formatter-number/src/formatter.test.ts::"unsupported locale throws UNSUPPORTED_LOCALE with cause"


Requirement: Per-(locale, options-hash) cache

The package SHALL maintain an internal Map<string, Intl.NumberFormat> keyed by ${locale}|${JSON.stringify(options)}. Repeated calls with identical (locale, options) SHALL reuse the same Intl.NumberFormat instance. The package SHALL export a clearNumberFormatterCache() test-only helper that empties the cache.

Scenario: identical options reuse the same Intl.NumberFormat

  • GIVEN the cache is empty
  • WHEN formatNumber(1, "en-GB", { style: "decimal" }) is called twice in sequence
  • THEN only one Intl.NumberFormat constructor invocation occurs (verified via spy on the global)

Scenario: distinct options create distinct cache entries

  • WHEN formatNumber(1, "en-GB", { style: "decimal" }) and formatNumber(1, "en-GB", { style: "percent" }) are called
  • THEN two distinct Intl.NumberFormat instances are created and cached

Scenario: clearNumberFormatterCache resets state

  • GIVEN the cache holds at least one entry
  • WHEN clearNumberFormatterCache() is called
  • THEN the next formatNumber call constructs a fresh Intl.NumberFormat

Verified by: packages/i18n/i18n-formatter-number/src/formatter.test.ts::"identical options reuse cached Intl.NumberFormat" AND packages/i18n/i18n-formatter-number/src/formatter.test.ts::"distinct options create distinct cache entries" AND packages/i18n/i18n-formatter-number/src/formatter.test.ts::"clearNumberFormatterCache resets cache state"


Requirement: OTel histogram i18n.format.number.duration_ms

The package SHALL emit an OTel histogram named exactly i18n.format.number.duration_ms (unit ms) on every successful formatNumber call. The histogram SHALL be tagged with locale and style.

Scenario: histogram emitted on every call

  • GIVEN an OTel test exporter capturing histogram observations
  • WHEN formatNumber(1, "en-GB", { style: "decimal" }) is called
  • THEN exactly one observation appears under i18n.format.number.duration_ms with locale: "en-GB" and style: "decimal" attributes

Verified by: packages/i18n/i18n-formatter-number/src/observability.test.ts::"emits i18n.format.number.duration_ms histogram on every call"


Requirement: OTel cache-hit counter i18n.format.cache_hit

The package SHALL emit an OTel counter i18n.format.cache_hit tagged with formatter: "number" on every cache hit (i.e. on the second and subsequent calls with identical (locale, options)). The counter SHALL NOT increment on the first (cache-miss) call.

Scenario: cache-hit counter increments only on second call

  • GIVEN an OTel test exporter capturing counter observations and an empty cache
  • WHEN formatNumber(1, "en-GB", { style: "decimal" }) is called twice in sequence
  • THEN the counter i18n.format.cache_hit{formatter="number"} shows exactly one increment

Verified by: packages/i18n/i18n-formatter-number/src/observability.test.ts::"cache-hit counter increments only on second call with same options"


Requirement: Ergonomic helpers — percent, compact, unit

The package SHALL export formatPercent, formatCompact, and formatUnit ergonomic shortcuts for the corresponding Intl.NumberFormat configurations. Each helper SHALL be a thin pre-binding of formatNumber (or, for formatUnit, a direct Intl.NumberFormat call because style: "unit" falls outside the typed NumberFormatterStyle).

Scenario: formatPercent multiplies by 100 per Intl semantics

  • WHEN formatPercent(0.5, "en-GB") is called
  • THEN the result equals "50%"

Scenario: formatCompact produces compact-notation output

  • WHEN formatCompact(1500, "en-US") is called
  • THEN the result equals "1.5K"

Scenario: formatUnit accepts unit identifiers

  • WHEN formatUnit(50, "kilometer-per-hour", "en-GB") is called
  • THEN the result contains the substring "50" AND the substring "km/h" (or locale-equivalent rendering)

Verified by: packages/i18n/i18n-formatter-number/src/helpers.test.ts::"formatPercent renders percent" AND packages/i18n/i18n-formatter-number/src/helpers.test.ts::"formatCompact renders 1.5K for 1500" AND packages/i18n/i18n-formatter-number/src/helpers.test.ts::"formatUnit renders unit identifier"


Requirement: Subpath exports

The package SHALL declare subpath exports in package.json so consumers can import only the surfaces they need:

  • @suaveplan/i18n-formatter-number — root barrel
  • @suaveplan/i18n-formatter-number/formatter — NumberFormatter class + formatNumber + types + schema only
  • @suaveplan/i18n-formatter-number/helpers — formatPercent, formatCompact, formatUnit only

Scenario: every documented subpath resolves

  • WHEN every documented subpath is imported in a consumer test fixture
  • THEN every import resolves with no Module not found errors

Verified by: packages/i18n/i18n-formatter-number/src/index.test.ts::"every documented subpath export resolves"


Requirement: Side-effect-free package

The package SHALL declare "sideEffects": false in package.json so bundlers can tree-shake unused exports.

Scenario: package.json declares sideEffects false

  • WHEN package.json is parsed
  • THEN the sideEffects field equals false

Verified by: packages/i18n/i18n-formatter-number/src/index.test.ts::"package.json declares sideEffects false"