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. (Seeopenspec/AGENTS.mdSection 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
I18nErrorwith 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)andnew 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
I18nErrorwith code"I18N.UNSUPPORTED_LOCALE"AND the error'scauseis the originalRangeError
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.NumberFormatconstructor invocation occurs (verified via spy on the global)
Scenario: distinct options create distinct cache entries
- WHEN
formatNumber(1, "en-GB", { style: "decimal" })andformatNumber(1, "en-GB", { style: "percent" })are called - THEN two distinct
Intl.NumberFormatinstances are created and cached
Scenario: clearNumberFormatterCache resets state
- GIVEN the cache holds at least one entry
- WHEN
clearNumberFormatterCache()is called - THEN the next
formatNumbercall constructs a freshIntl.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_mswithlocale: "en-GB"andstyle: "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—NumberFormatterclass +formatNumber+ types + schema only@suaveplan/i18n-formatter-number/helpers—formatPercent,formatCompact,formatUnitonly
Scenario: every documented subpath resolves
- WHEN every documented subpath is imported in a consumer test fixture
- THEN every import resolves with no
Module not founderrors
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.jsonis parsed - THEN the
sideEffectsfield equalsfalse
Verified by: packages/i18n/i18n-formatter-number/src/index.test.ts::"package.json declares sideEffects false"