Skip to content

feat(core)!: separate currency from language, with a validated cross-language matrix - #428

Open
forzagreen wants to merge 47 commits into
mainfrom
feat/currency-matrix-bare-tags
Open

feat(core)!: separate currency from language, with a validated cross-language matrix#428
forzagreen wants to merge 47 commits into
mainfrom
feat/currency-matrix-bare-tags

Conversation

@forzagreen

@forzagreen forzagreen commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Pull Request

Status — split done, rebased onto both. The two pieces @TylerVigario asked for have
landed on main and this branch is rebased on top of them:

What remains is the language/currency separation itself, and one open design question —
see the sequencing comment.

What does this do?

A language and a currency are two different things. n2words was treating them as one, and this
PR separates them.

A language is spoken across many countries; a currency belongs to exactly one. But n2words gave
every country its own language file — 16 English files, identical numeral logic in 12 of them,
each one hardcoding a single currency's words and therefore able to name that one currency and
nothing else. Asking en-GB for Kenyan shillings was impossible, even though "forty-two
shillings and fifty cents" is plain English. You had to import en-KE — a 450-line duplicate of
en-GB whose only real difference was the word "shilling".

Currency words now live on their own axis, keyed by language, so every entry point of a
language can name every currency that language has words for:

                    WHICH CURRENCIES CAN THIS ENTRY POINT NAME?

  before — one file, one currency          after — one `en` matrix, shared by all 16
  ─────────────────────────────────        ────────────────────────────────────────
        USD CAD AUD GBP KES INR …                USD CAD AUD GBP KES INR …
  en-US  ●   ·   ·   ·   ·   ·             en-US  ●   ●   ●   ●   ●   ●
  en-CA  ·   ●   ·   ·   ·   ·             en-CA  ●   ●   ●   ●   ●   ●
  en-AU  ·   ·   ●   ·   ·   ·             en-AU  ●   ●   ●   ●   ●   ●
  en-GB  ·   ·   ·   ●   ·   ·             en-GB  ●   ●   ●   ●   ●   ●
  en-KE  ·   ·   ·   ·   ●   ·             en-KE  ●   ●   ●   ●   ●   ●
  en-IN  ·   ·   ·   ·   ·   ●             en-IN  ●   ●   ●   ●   ●   ●
    …                                        …
  16 locales × 1 currency = 16             16 locales × 24 currencies = 384
import { toCurrency } from 'n2words/en-GB'
toCurrency(42.50, { currency: 'KES' })  // before: TypeError — no such option
                                        // after:  'forty-two shillings and fifty cents'

Full detail, including the diagram and the two bugs this surfaced, in The core change:
currency and language are separate axes
— the very next section. It's also written up
permanently in docs/language-layers.md.

Everything else in the PR is either what made that possible or what fell out of it:

  • src/utils/currency-vocab.js — the new shared matrix, 54 exports keyed by language
    (not locale), covering 50 distinct currencies. Currency word-data moves out of the language
    files; pluralization rules stay per-file.
  • toCurrency's currency option becomes a real validated enum, with its allowed set
    derived from the matrix rather than hand-typed. On main it was honored by exactly one of
    72 languages; the other 71 ignored or rejected it.
  • 13 files stop being copies. en-AU, en-BD, en-GH, en-IE, en-KE, en-MY, en-NG,
    en-NZ, en-PH, en-PK, en-SG, en-ZA and es-MX were behavioural clones differing only
    in default currency; they become ~40-line locale profiles. ~6,400 lines deleted. en-CA
    looked like a clone too and isn't — see 14 clone files collapse into
    locale profiles
    , below.
  • Two real bugs found and fixed, both previously unreachable, both reachable the moment a
    language can name a second currency: a grammatical-gender mismatch across every
    Slavic/Baltic/Romanian/Arabic/Spanish language, and an undefined-rendering bug for
    currencies whose noun doesn't pluralize (taka, ringgit, naira, rand).
  • 1000-subunit currencies (millimes, fils) are nameable at last — the parser's fixed
    two-decimal precision previously ruled out TND, KWD, BHD, OMR, JOD, IQD and LYD entirely.
  • 46 bare-tag alias files make import … from 'n2words/de' the primary import path.
    zh, pt, sr, am stay region/script-qualified only.
  • Four new CI gates hold all of the above in place.
  • Counts corrected: "70+ languages" → 50 languages, 72 regional variants (the old number
    counted variants as languages).

⚠️ This is a breaking release (v6.0.0) — one break, in three cases, all consequences of
the currency option becoming real. Full before/after under ⚠️ Breaking changes, further
down. The fractional-amount RangeError that an earlier revision of this description listed as
a second break shipped separately in #430 and is not re-declared here.

Rationale docs: docs/language-layers.md, docs/currency-vocab.md, docs/bare-tag-aliases.md.


The core change: currency and language are separate axes

Not breaking — verified directly against main; see Explicitly not affected, near the
end. This section is the evidence for the summary above.

The three layers

A BCP 47 code like en-KE was encoding three unrelated facts in one string. They don't vary
together — that's the whole point — so n2words now names each one separately:

flowchart TD
    E["What you import — 16 English entry points<br/>n2words/en-US · en-GB · en-AU · en-KE · en-IN · en-ZA · ..."]

    E --> L1
    E --> L2
    E --> L3

    subgraph S1["LAYER 1 — Numerals · varies by language VARIETY"]
        L1["4 full implementations<br/>en-US · en-GB · en-IN · en-CA<br/>16 entry points collapse to 4 behaviours"]
    end

    subgraph S2["LAYER 2 — Currency words · varies by LANGUAGE"]
        L2["1 shared matrix — en<br/>24 currencies · GBP KES AUD USD INR MYR ...<br/>16 entry points share 1 vocabulary"]
    end

    subgraph S3["LAYER 3 — Default currency · varies by COUNTRY"]
        L3["16 country defaults<br/>en-US → USD · en-KE → KES · en-AU → AUD ...<br/>16 entry points, 16 distinct values"]
    end
Loading

The cardinalities are the argument. Across the same 16 entry points the three layers have
4, 1 and 16 distinct values. One filename cannot encode three facts with three
different arities without duplicating something — and what got duplicated was layer 1, the
numeral logic, which is the hard part and the part you least want copied 16 times.

Layer Varies by Lives in Distinct values across English
1 · Numerals language variety a full src/{code}.js implementation 4
2 · Currency words language src/utils/currency-vocab.js, keyed by language 1 (en, 24 currencies)
3 · Default currency country a thin src/{code}.js with variantOf 16

The measurement that started this

Running every English variant over ~1,300 probe values and grouping by identical output:

Files Distinct behaviours
Numerals (toCardinal/toOrdinal) 16 3 under default options — Commonwealth (×12), South Asian (×3), en-US
Currency reachability (on main) 16 16 — each locale knew its own currency and no other

en-AU vs en-SG differed by zero lines of logic — the entire diff between the two files
was doc comments. The file count was tracking currency, not language.

(Layer 1 ends up with 4 implementations rather than 3 because en-CA exposes an option the
others don't; see 14 clone files collapse into locale profiles, below.)

It generalizes past English

English is where the duplication was worst, but the model is language-agnostic:

Language Entry points Layer 1 — numeral impls Layer 2 — currency matrix Layer 3 — profiles
English 16 4 — en-US, en-GB, en-IN, en-CA en24 currencies 12
Spanish 3 2 — es-ES, es-US es — 3 1 (es-MX)
French 2 2 — fr-FR, fr-BE fr — 3 0
Arabic 1 1 — ar-SA ar9 0
Portuguese 2 2 — pt-BR, pt-PT ptBR / ptPT — kept split 0

Arabic is the clearest demonstration that layer 2 does the work on its own. One entry point,
zero new files, and it now names nine Arab-world currencies:

import { toCurrency } from 'n2words/ar'
toCurrency('3.003', { currency: 'KWD' })  // 'ثلاثة دنانير وثلاثة فلوس'
toCurrency(42.50,   { currency: 'MAD' })  // 'اثنان وأربعون درهماً وخمسون سنتيماً'

Arabic numerals don't change between Riyadh and Casablanca, so there is no ar-MA file and no
reason for one — the currency was never a property of the language. Portuguese is the
counterexample that proves layer 2 is keyed correctly: pt-BR and pt-PT name the same EUR
cent with genuinely different words (centavo vs cêntimo), so their matrices stay split, as
do the zh/sr/am script pairs — the same set docs/bare-tag-aliases.md's "very different"
test already excludes from a bare-tag alias.

What it unlocks

Every one of these throws TypeError: Unknown option "currency" on main:

import { toCurrency as gb } from 'n2words/en-GB'
import { toCurrency as ke } from 'n2words/en-KE'

gb(42.50, { currency: 'KES' })  // 'forty-two shillings and fifty cents'
ke(42.50, { currency: 'GBP' })  // 'forty-two pounds and fifty pence'
//                                             ^^^^^ note: pence, not cents —
//                                 the minor unit follows the currency, not the locale
// an invariable-noun currency, from any English entry point
toCurrency(2, { currency: 'NGN' })   // en-US: 'two naira'    (not 'two nairas')
toCurrency(2, { currency: 'MYR' })   // en-CA: 'two ringgit'
toCurrency('1.500', { currency: 'TND' })  // en-ZA: 'one dinar and five hundred millimes'

An app rendering two currencies in English used to have to import two behaviourally-identical
450-line modules. Now it imports one and passes an option.

Every <form>Values currency union is keyof typeof <language> instead of a hand-typed literal
(EnCurrency, EsCurrency, …), so widening a language's map widens every file referencing it
in the same edit, and a typo in either place fails typecheck.

14 clone files collapse into locale profiles — 13 did, one didn't

en-AU, en-BD, en-GH, en-IE, en-KE, en-MY, en-NG, en-NZ, en-PH, en-PK,
en-SG, en-ZA, and es-MX turned out to be behavioural clones of a base (en-GB, en-IN,
or es-ES) under default options. Each becomes a locale profile — export * from its base plus
a toCurrency wrapper applying just its own default:

// src/en-AU.js — the entire currency-relevant part
import { resolveOptions } from './utils/resolve-options.js'
import { toCurrency as toCurrencyBase, currencyValues } from './en-GB.js'

export * from './en-GB.js'
export const variantOf = 'en-GB'

export const currencyDefaults = { and: true, currency: 'AUD' }

function toCurrency(value, options) {
  return toCurrencyBase(value, resolveOptions(options, currencyDefaults, currencyValues))
}
export { toCurrency }

en-CA looked like the same clone by every default-value probe — until a second check (does
this file export any options the others don't) found hundredPairing ("fifteen hundred" vs
"one thousand five hundred"), an option no other English variant exposes. Collapsing it would
have silently deleted a documented feature. en-CA stays a full implementation; everything else
listed above became a profile. Net: ~6,400 lines of duplicated numeral logic removed.

Two real bugs the widening surfaced

Both were latent, not shipped — unreachable before this PR, reachable the moment a language
could name more than one currency, which is exactly what this PR does. Both are fixed here.

Grammatical gender. ru-RU, uk-UA, pl-PL, hr-HR, lt-LT, lv-LV, ro-RO,
sr-Cyrl-RS, sr-Latn-RS, es-*, and ar-SA each hardcoded their single currency's noun
gender as a literal in toCurrency (integerToWords(rubles, 'masculine')). That was already
wrong the instant a second, differently-gendered currency became nameable:

// ru-RU, gender read from the currency's own data instead of hardcoded
toCurrency(2)                        // 'два рубля'   — рубль is masculine, два is correct
toCurrency(2, { currency: 'UAH' })   // 'дві гривні'  — гривня is feminine, дві is correct
//                                       (a hardcoded 'masculine' would have produced
//                                        'два гривня' — well-formed, wrong gender)

majorGender/minorGender move into the matrix next to the word forms they describe; ar-SA's
own MINOR_GENDER table folds into this and is deleted. New gate:
currency-vocab-contract.test.js now checks each gender field independently — if any entry for
a language declares majorGender, every entry must (same for minorGender and any entry with a
minor unit) — so a currency added without one fails CI instead of shipping a mismatch.

Invariable-noun currencies. taka (BDT), ringgit/sen (MYR), naira/kobo (NGN), and
rand (ZAR) don't pluralize — a one-element word-form array, not the usual
[singular, plural] pair. The English bases indexed major[1] for any count past 1
unconditionally:

// before the fix, reachable the moment en's matrix included an invariable-noun currency
toCurrency(2, { currency: 'BDT' })  // 'two undefined'   ← major[1] doesn't exist
// after
toCurrency(2, { currency: 'BDT' })  // 'two taka'        ← major.length < 2, so major[0]

Fixed by checking array length before assuming a plural form exists
(count === 1n || major.length < 2 ? major[0] : major[1]), applied in en-US, en-GB,
en-IN, and en-CA — the four files with real currency-building logic; every profile
delegating to one of the first three inherits the fix automatically.

The trap a naive collapse would have shipped

A profile that only shadowed currencyDefaults via export *, without the toCurrency
wrapper, would be silently wrong: the base's toCurrency closes over the base's own
module-scope currencyDefaults, so en-AU would report currencyDefaults.currency === 'AUD'
while every call with no options still returned pounds. Verified this is real (not theoretical)
with a minimal ES-module reproduction before writing the profiles. New gate,
variant-profile-contract.test.js, exists specifically to catch a regression to that shape: for
every profile, it asserts profile.toCurrency(v) with no options equals
base.toCurrency(v, { currency: profileDefault }) called explicitly, across a probe set
including 0/1/2 (the singular/dual boundary) and a fraction.

Updated gates

  • bare-tag-contract.test.js — a profile (variantOf) is now excluded from counting as a
    family's "canonical" variant, same as an alias (aliasOf) already was, so a bare tag can
    never resolve to a profile instead of the real implementation it delegates to.
  • currency-vocab-contract.test.js — the expected matrix export name is now derived from
    the code's primary subtag (en-KEen) instead of the old full-locale camelCase
    (enKE), with a small override list for the languages that stay split
    (pt-BRptBR, zh-Hans-CNzhHans, ...). Profiles are skipped for the same reason
    aliases already were — their currencyValues is a re-export, not a separate declaration.

LANGUAGES.md's currency table, before it would have gotten worse

Widening en's reachability turns "72 variants name 50 currencies across 96 pairs" into
462 pairs — accurate, but a naive table would repeat "en-AU, en-BD, en-CA, ...,
en-ZA" (16 near-identical entries) in nearly every row. The generator now collapses a
family's contribution to its bare primary subtag whenever every variant reaches a currency
together:

-|`EUR`|`de-DE`, ...|`en-AU`, `en-BD`, `en-CA`, `en-GB`, `en-GH`, `en-IE`, `en-IN`, `en-KE`,
-       `en-MY`, `en-NG`, `en-NZ`, `en-PH`, `en-PK`, `en-SG`, `en-US`, `en-ZA`, `es-MX`,
-       `es-US`, `pt-BR`|
+|`EUR`|`de-DE`, ...|`en`, `es`, `pt`|

Bundle-size honesty

Widening en's matrix has a real, measured cost — every English bundle now carries every
currency any English locale names, not just its own. Built dist/ with rollup to measure it
rather than estimate, against main directly (abd02f2) — this is the number that matters
to a consumer upgrading, since it's the entire cost of this PR's currency-matrix work, not just
the layering step on top of it. Re-checked after the rebase onto 1fe4e02: every figure below
is unchanged, since #430 touches only the five zero-exponent languages and #431 adds files
without altering the combined bundles:

main this branch Delta
en-US.js 7,778 B 9,227 B +1,449 B
en-GB.js 7,434 B 8,886 B +1,452 B
en-AU.js (profile) 7,435 B 8,974 B +1,539 B
en-KE.js (profile) 7,439 B 8,974 B +1,535 B
es-ES.js 8,483 B 9,211 B +728 B
es-MX.js (profile) 8,481 B 9,296 B +815 B

main has no currency-vocab.js at all — toCurrency on main hardcodes its own words inline
— so this delta is the full cost of gaining a validated, cross-currency-capable currency
option, not a layering-specific regression. Isolated one level further, just the language-layers
step on top of the rest of this PR's already-landed currency-matrix work (i.e. its own before/after,
measured the same way against the pre-layering commit within this branch, 9503e01) costs far
less — rekeying the matrix from locale to language, alone:

before layering (9503e01) after (this branch) Delta
en-US.js 8,628 B 9,227 B +599 B
en-GB.js 7,970 B 8,886 B +916 B
en-AU.js (profile) 7,971 B 8,974 B +1,003 B
es-ES.js 8,864 B 9,211 B +347 B

Note en-AU.js and en-KE.js land at the same 8,974 B in the main-relative table — both
profiles bundle the identical en matrix, differing only in which default-currency string is
embedded. A profile's bundle is not smaller than its base's either way — rollup.config.js
produces self-contained bundles with no runtime cross-file imports, so a profile pays the same
matrix cost plus a ~60–90 B wrapper. The win from collapsing 14 files into profiles is
source-side (~6,400 lines deleted), not bundle-side; docs/currency-vocab.md's "Why keyed by
language, not by currency" section has the full accounting.

Verification

Two different comparisons answer two different questions, and this section keeps them separate
on purpose:

Against main directly (1fe4e02, after #430 and #431 merged) — is any of this breaking,
full stop:

  • toCardinal/toOrdinal: 0 differences, every one of main's 72 codes, ~1,300 probe
    values each.
  • toCurrency under default options: 0 diffs. An earlier revision reported 40, all in the
    five zero-exponent languages; fix: reject fractional amounts for currencies with no minor unit, and unbreak the docs generator #430 landed that guard on main, so main and this branch now
    agree. Re-measured after the rebase, not carried over: 90 comparisons across ja-JP, ko-KR,
    vi-VN, fa-IR, id-ID, zero differences.
  • toCurrency under the currency option: exactly the 1,136 diffs already declared in §1
    below
    — nothing new, and unchanged by the rebase (fix: reject fractional amounts for currencies with no minor unit, and unbreak the docs generator #430 added no options to those five
    files). Together with the line above this is the same sweep described in How was this
    tested?
    , confirming the language-layers work didn't introduce a break it would have
    caught.
  • Cross-currency capability spot-checked directly against main's absence of the feature
    entirely (en-GB+KES, en-KE+GBP, en-CA+NGN, and every invariable-noun currency named above).

Step-by-step within the branch (matrix rekey, exponent/arity fixes, gender migration, profile
collapse) — re-run after each structural step, not just once at the end, because this is how
regressions were actually caught during development, not by inspection:

  • toCardinal/toOrdinal: 0 differences at every step.
  • toCurrency under each entry point's own default options: 0 differences at every step. The
    only diffs across the whole sequence are the 13 new variantOf metadata lines the profiles
    introduce — not observable through any public function.
  • This is specifically what caught the en-CA/hundredPairing and invariable-noun bugs: a
    diff against main alone wouldn't isolate which of this PR's several structural steps
    introduced a given regression, so the granular replay is what made them findable at all.

npm test762 tests passed on this branch, vs. 453 on main (+309) — measured on both,
not estimated:

Test file main this branch Δ
contract.test.js 72 72 0
conversions.test.js 76 122 +46
options-contract.test.js 41 72 +31
range-contract.test.js 72 72 0
test/utils/*.test.js (5 files) 192 192 0
bare-tag-contract.test.js 89 +89
currency-vocab-contract.test.js 129 +129
variant-profile-contract.test.js 14 +14
Total 453 762 +309

The three new files are the three new gates this PR adds; conversions.test.js and
options-contract.test.js grew because every currency-exporting language now has real
currency-option coverage main never had. Isolated one level further (just the language-layers
step, measured the same way against the pre-layering commit 9503e01 within this branch, the
number quoted earlier in this PR's history): 771 → 762, a −9 net — 13 profile files no longer
re-verify a currency matrix already proven by the base they delegate to
(currency-vocab-contract.test.js alone: 152 → 129), offset by the +14 new
variant-profile-contract.test.js adds. That −9 is real but it's a statement about one step
within this PR, not about this PR versus main — the table above is the number that matters to
someone deciding whether to merge this. npm run lint clean on both.


⚠️ Breaking changes

Every before/after below was produced by running the actual code on main (1fe4e02, current
as of this writing, after #430 and #431 merged) and on this branch, not from memory. There is
one break, in three cases, all declared in the BREAKING CHANGE: footer on the
feat(core)! commit so they reach the generated release notes — see Release-notes footer at
the bottom for a parser bug that was truncating that footer.

The fractional-amount RangeError for zero-exponent currencies (JPY, KRW, VND, IRR, IDR) was
listed here as a second break in an earlier revision. It shipped in #430 as a fix: and is
already on main, so this PR no longer introduces it and it is not re-declared. What this PR
still adds is the cross-language reach of that same guard — see Non-breaking improvements
below.

At a glance

# Change Blast radius Old result New result
1a currency option silently ignored 41 languages option discarded, local currency returned RangeError
1b currency option unknown 30 languages TypeError RangeError (or now works, if it's the language's own currency)
1c pt-BR currency free-form → enum pt-BR only code echoed / auto-detected RangeError

1. currency is now a validated enum

On main the currency option was honored by exactly one language (pt-BR). The other 71
either ignored it or rejected it as unknown. It is now a real, validated enum everywhere,
which changes the behavior of all three groups.

1a. 41 languages silently ignored the option — now RangeError

On main these declared function toCurrency(value) — no options parameter at all — so JS
discarded the second argument without a word. You got your local currency back and no
indication that your request was dropped:

import { toCurrency } from 'n2words/hi-IN'

toCurrency(5, { currency: 'USD' })
// main:        'पाँच रुपये'   ← "five rupees". Asked for dollars, got rupees, no error.
// this branch: throws RangeError: Option "currency" must be one of: INR — got "USD"
// same shape everywhere:
'n2words/ur-PK'  toCurrency(5, { currency: 'USD' })  // main 'پانچ روپے'    (five rupees)
'n2words/pl-PL'  toCurrency(5, { currency: 'USD' })  // main 'pięć złotych' (five zloty)
'n2words/ja-JP'  toCurrency(5, { currency: 'USD' })  // main '五円'          (five yen)
// all four now throw RangeError naming the one code they accept
All 41 languages (click to expand)

am-ET, am-Latn-ET, ar-SA, az-AZ, bn-BD, cs-CZ, da-DK, el-GR, fa-IR, fi-FI,
fil-PH, gu-IN, ha-NG, hbo-IL, he-IL, hi-IN, hr-HR, hu-HU, id-ID, ja-JP,
ka-GE, kn-IN, ko-KR, lt-LT, lv-LV, mr-IN, ms-MY, nb-NO, pa-IN, pl-PL,
ro-RO, sv-SE, sw-KE, ta-IN, te-IN, th-TH, tr-TR, uk-UA, ur-PK, vi-VN,
yo-NG

This break only fires on code that was already broken — the option never did anything in
these languages, so any caller passing it was already getting the wrong currency silently.

1b. 30 languages threw TypeError — now RangeError, or succeed

These already took an options object (for and / formal), so an unknown currency key was
a TypeError. Two outcomes now, depending on the code you pass:

import { toCurrency } from 'n2words/en-US'

toCurrency(5, { currency: 'EUR' })
// main:        throws TypeError: Unknown option "currency" — expected one of: and
// this branch: throws RangeError: Option "currency" must be one of: USD — got "EUR"
//              ^^^^^^^^^^ error TYPE changed — `catch (e) { if (e instanceof TypeError) … }` breaks

toCurrency(5, { currency: 'USD' })
// main:        throws TypeError
// this branch: 'five dollars'     ← now works ✅ (its own currency is in the enum)
import { toCurrency } from 'n2words/fr-FR'
toCurrency(5, { currency: 'EUR' })
// main:        throws TypeError: Unknown option "currency" — expected one of: and
// this branch: 'cinq euros'       ← now works ✅

The only genuinely breaking part here is the error type: TypeErrorRangeError. This
is deliberate and consistent with the rest of the options contract — resolveOptions throws
TypeError for a malformed option (unknown key, wrong type) and RangeError for a
well-formed but out-of-set value. currency is now a known key, so an unsupported value is
a range problem, not a type problem.

All 30 languages (click to expand)

de-DE, en-AU, en-BD, en-CA, en-GB, en-GH, en-IE, en-IN, en-KE, en-MY,
en-NG, en-NZ, en-PH, en-PK, en-SG, en-US, en-ZA, es-ES, es-MX, es-US,
fr-BE, fr-FR, it-IT, nl-NL, pt-PT, ru-RU, sr-Cyrl-RS, sr-Latn-RS,
zh-Hans-CN, zh-Hant-TW

1c. pt-BR: free-form string → validated enum

pt-BR is the one language where currency actually worked, so it's the one with real
migration work. Three previously-accepted inputs now throw:

import { toCurrency } from 'n2words/pt-BR'

// (i) the PREVIOUS DOCUMENTED DEFAULT — '' auto-detected BRL
toCurrency(5,    { currency: '' })     // main 'cinco reais'                   → RangeError
toCurrency(5.50, { currency: '' })     // main 'cinco reais e cinquenta centavos' → RangeError
toCurrency(5)                          // 'cinco reais' — unchanged ✅ (default is now 'BRL')
toCurrency(5,    { currency: 'BRL' })  // 'cinco reais' — the explicit spelling ✅

// (ii) lowercase codes were uppercased for you
toCurrency(5, { currency: 'brl' })     // main 'cinco reais'                   → RangeError

// (iii) an unknown code echoed itself as the major word
toCurrency(5,    { currency: 'CAD' })  // main 'cinco CAD'                     → RangeError
toCurrency(5.50, { currency: 'CAD' })  // main 'cinco CAD e cinquenta centavos' → RangeError
toCurrency(1,    { currency: 'XYZ' })  // main 'um XYZ'                        → RangeError

That last one is the reason the enum exists: main would cheerfully emit 'um XYZ' for any
three letters you handed it, mixing an ISO code into spelled-out Portuguese.

Still works, unchanged — the currencies pt-BR genuinely has words for:

toCurrency(42.50, { currency: 'USD' })  // 'quarenta e dois dólares e cinquenta centavos' ✅
toCurrency(42.50, { currency: 'EUR' })  // 'quarenta e dois euros e cinquenta centavos'   ✅
toCurrency(5,     { currency: 'GBP' })  // 'cinco libras'                                 ✅
toCurrency(100,   { currency: 'JPY' })  // 'cem ienes'                                    ✅

The authoritative set is exported: currencyValues.currency is
['BRL', 'USD', 'EUR', 'GBP', 'JPY'].

Migrating (all of §1)

The allowed set is introspectable at runtime, so you never have to guess:

import { toCurrency, currencyValues, currencyDefaults } from 'n2words/pt-BR'

currencyDefaults.currency          // 'BRL'
currencyValues.currency            // ['BRL', 'USD', 'EUR', 'GBP', 'JPY']

if (currencyValues.currency.includes(code)) {
  toCurrency(amount, { currency: code })
} else {
  // this language has no words for `code` — pick another language or omit the option
}
  • Passing { currency: '' } → drop the option, or pass the explicit code.
  • Passing a lowercase code → .toUpperCase() it.
  • Catching TypeError around toCurrency → catch RangeError too (or just Error).

New currencies: millimes, fils, and the Moroccan dirham

Not breaking — additive, and a direct consequence of the layer-2 matrix above:
parseCurrencyValue's new digit-count argument defaults to 2, so
every existing call is unchanged.

Most currencies divide into 100; a few divide into 1000, and the fixed two-decimal parser ruled
all of them out. Naming one at two digits isn't a partial answer, it's a wrong one:

// what a 2-digit parse would have produced
toCurrency('1.500', { currency: 'TND' })  // 'one dinar and fifty millimes'
                                          //  ...for an amount meaning five hundred

Now supported: TND (millime), KWD/BHD/JOD/IQD (fils), OMR (baisa), LYD (dirham).

import { toCurrency } from 'n2words/en'

toCurrency('1.500',  { currency: 'TND' })  // 'one dinar and five hundred millimes'
toCurrency('1.050',  { currency: 'TND' })  // 'one dinar and fifty millimes'
toCurrency('0.001',  { currency: 'TND' })  // 'one millime'
toCurrency('42.750', { currency: 'KWD' })  // 'forty-two dinars and seven hundred fifty fils'
toCurrency('2.002',  { currency: 'OMR' })  // 'two rials and two baisas'
toCurrency('1.500',  { currency: 'USD' })  // 'one dollar and fifty cents'  ← still 2 digits
import { toCurrency } from 'n2words/fr'
toCurrency('42.750', { currency: 'TND' })  // 'quarante-deux dinars et sept cent cinquante millimes'

How it works

CURRENCY_EXPONENTS now holds 3 alongside 0 (2 stays the implicit default for anything
absent), and minorUnitDigits(currency) derives the parser argument from it. Because the digit
count depends on the resolved currency, a language naming one of these resolves options
before parsing:

const { currency } = resolveOptions(options, currencyDefaults, currencyValues)
const { isNegative, dollars, cents } = parseCurrencyValue(value, minorUnitDigits(currency))

minorUnitDigits deliberately returns 2, not 0, for a zero-exponent currency like JPY —
parsing those at 0 digits would silently turn 1.5 yen into 1 yen, exactly the truncation
assertCurrencyExponent exists to refuse.

Coverage

Arabic number words are variant-independent while currency is country-specific, so arSA is
the single home for the whole Arab-world set.

Language Currencies added
en-US (so also n2words/en) all seven + MAD — English is where these are quoted internationally
fr-FR, fr-BE TND + MAD — francophone Maghreb
ar-SA all seven + MAD

MAD is not one of them

The Moroccan dirham divides into 100 santim, not 1000 — so despite sitting among the
dinars it's an ordinary 2-decimal currency: no CURRENCY_EXPONENTS entry, parsed at the
default precision. Flagged at both definition sites, since landing it beside the 1000-subunit
work invites exactly the wrong assumption.

toCurrency(42.50, { currency: 'MAD' })  // en  'forty-two dirhams and fifty centimes'
                                        // fr  'quarante-deux dirhams et cinquante centimes'
                                        // ar  'اثنان وأربعون درهماً وخمسون سنتيماً'

درهم is reused rather than duplicated — it's Libya's minor unit and Morocco's major one,
the same word in two roles. Its Arabic minor unit سنتيم is masculine, so MINOR_GENDER carries
it and 3 santim renders ثلاثة سنتيمات, not the feminine ثلاث.

LANGUAGES.md regenerated: 50 distinct currencies (was 42) across 96 language/currency
pairs (was 76).

An Arabic grammar bug this surfaced

ar-SA hardcoded its minor unit as feminine — correct for هللة, wrong for مليم, فلس and درهم.
Arabic inverts numeral gender for 3–10, so a masculine noun takes the ة-marked numeral:

3.003 KWD   before: ثلاثة دنانير وثلاث فلوس     ← ثلاث, feminine — wrong for فلس
            after:  ثلاثة دنانير وثلاثة فلوس    ← ثلاثة, masculine
11.011 KWD  after:  أحد عشر ديناراً وأحد عشر فلساً
3.003 OMR   after:  ثلاثة ريالات وثلاث بيسات     ← بيسة is feminine, so ثلاث is right here

A new MINOR_GENDER map drives the selection per currency. This is grammar, so it lives in
ar-SA.js, not the shared matrix. SAR output is unchanged — it was already feminine.

The bug was unreachable before this PR (ar-SA named only SAR), so nothing shipped wrong.

The gate

currency-vocab-contract.test.js proves the round trip behaviorally rather than trusting the
declaration: for every 3-decimal currency a language advertises, '1.500' and '1.050' must
not render alike — which they do the instant a language forgets to pass the digit count. A
language can't declare one of these currencies and quietly mis-parse it.


Bare-tag aliases

Purely additive — no existing specifier changes meaning. n2words/en-GB and every other
region/script-qualified import keeps working exactly as before.

What ships

46 alias files, one per language that can safely have a default variant:

// src/de.js — the entire file, modulo its header comment
export * from './de-DE.js'
export const aliasOf = 'de-DE'

No package.json change was needed: the exports map is already a wildcard
("./*""./src/*.js"), so n2words/de resolves the moment src/de.js exists. Types come
along the same way via typesVersions. ./utils/* stays walled off (null).

The 50 languages break down as:

Count Alias?
Single-variant families 43 ✅ all 43 — nothing to be ambiguous among
Multi-variant families that pass the test 3 — en (16 variants), es (3), fr (2) enen-US, eses-ES, frfr-FR
Multi-variant families that fail it 4 — zh, sr, am, pt ❌ region/script-qualified only

43 + 3 = 46 aliases, 72 variant files, 50 languages.

The rule: what makes a family "very different"

The interesting design question is why en gets an alias when its variants disagree wildly,
while pt — only two variants — doesn't. The test is deliberately narrow: would defaulting
silently change the shape of the output
, not "do these variants differ a lot".

Two things fail it, and I verified both by running the code:

Different script — same grammar, same words, different Unicode:

zh-Hans-CN(42)  '肆拾贰'   ·  zh-Hant-TW(42)  '肆拾貳'    // identical structure
sr-Cyrl-RS(1)   'један'   ·  sr-Latn-RS(1)   'jedan'    // literally the same word
am-ET(11)       'አስራ አንድ' ·  am-Latn-ET(11)  'asra and'

For zh, 4 of 7 sampled values render byte-identically; the pairs diverge only where a
character has a distinct traditional form (/, 亿/). A bare zh would be picking
a script on the caller's behalf, invisibly.

Different core numbering system — this is the one that would actually corrupt numbers:

pt-BR(1e9)   'um bilhão'        ·  pt-PT(1e9)   'um mil milhões'
pt-BR(1e12)  'um trilhão'       ·  pt-PT(1e12)  'um bilião'
pt-BR(16)    'dezesseis'        ·  pt-PT(16)    'dezasseis'

Brazil is short-scale, Portugal is long-scale. Note that bilhão and bilião are the same
word to the eye and differ by a factor of 1000
— a bare pt defaulting either way wouldn't
produce awkward output, it would produce confidently wrong numbers. That's why pt is
excluded even though it has only two variants, while 16-variant en is fine.

Why en passes anyway

Because an alias is a fixed pointer to one variant, not a dispatcher. enen-US makes
no claim about its siblings, several of which genuinely diverge:

en(1e7)     'ten million'   ·  en-IN(1e7)  'one crore'   // Indian lakh/crore grouping
es-ES        EUR, max 10³⁰  ·  es-MX  MXN, max 10³⁰  ·  es-US  USD, max 10²¹

Those differences are real, but they're differences between variants you asked for by name
not something the bare tag silently resolves.

The gate: test/bare-tag-contract.test.js

Two mechanical properties (the "very different" judgment stays human, as it must):

  • Completeness — every primary subtag with exactly one variant in src/ must have an
    alias. This is what stops the backfill from being a one-time cleanup that rots.
  • Fidelity — every re-exported binding is reference-identical (===) to its target's,
    proving export * forwards live bindings rather than a stale copy.

Because fidelity is proven here, the four other contract gates skip alias files outright
(if (mod.aliasOf !== undefined) continue) rather than re-fuzzing functions already known to
be the same object.

I verified independently of the gate: all 46 aliases have a valid in-family aliasOf, all 46
are reference-identical across every exported binding, all 46 match the two-line shape, and
alias/target form coverage agrees in all 46 cases. Zero deviations.

Tooling and bundles

  • npm run lang:add -- de is refused — bare codes are reserved, so a typo can't scaffold
    a full implementation where the gate expects a re-export (add-language.js:682).
  • First variant in a family → alias scaffolded automatically. Joining an existing family →
    prints a note instead, because repointing an alias is a human call.
  • dist/ gets a real bundle per alias, not a stub. Deliberate: a UMD bundle has no module
    loader to follow a re-export, so dist/en.umd.js must be self-contained — and it isn't even
    byte-equal to en-US.umd.js, since it exposes the documented n2words.en global. src/
    consumers never pay for this; it costs npm tarball size only.

Explicitly not affected

Verified by differential sweep against main, not by assertion:

  • toCardinal and toOrdinal: 0 differences. Every language, over 0, 1, 7, 42, 100, 1000, 1234567, -5, 3.14, 999999999n. This PR touches currency only.
  • Every language's default-currency output is unchanged — the vocabulary extraction into
    the matrix is a pure move, and against current main the default-options sweep finds 0
    differences
    in every language, fractional amounts included (the 40 fractional cases an
    earlier revision listed here are on main now, via fix: reject fractional amounts for currencies with no minor unit, and unbreak the docs generator #430).
  • No option was removed or renamed anywhere, in any form. Across all 72 languages × 3
    forms the only changed default value is pt-BR's currency: '' → 'BRL'; every other change
    is a new currency key added to an existing defaults object (e.g. en-US {and: true}
    {and: true, currency: 'USD'}).
  • Bare-tag aliases are non-breaking — purely additive, and no package.json exports
    change was required. See the bare-tag section above for the full verification.
  • The language-layers restructuring (matrix rekey, 13 files becoming profiles, the two bugs
    it surfaced) is non-breaking and output-neutral
    — every existing import specifier, every
    default currency, and every toCardinal/toOrdinal/toCurrency(defaults) output is
    unchanged, verified directly against main: the only toCurrency diffs found are the
    same 1,136 (currency option) already declared in §1 above, with 0 under default options,
    nothing attributable to the layering step specifically. It was additionally verified step-by-step
    within the branch, re-run after each structural change rather than once at the end — see its
    own Verification subsection for both comparisons and why they're kept separate.

Non-breaking improvements that come with this

  • The zero-subunit guard now reaches cross-language. fix: reject fractional amounts for currencies with no minor unit, and unbreak the docs generator #430 made a fractional JPY/KRW/VND/
    IRR/IDR amount throw in the five languages whose own currency it is. Because the check is on
    the currency rather than the language, the matrix extends it to any language naming one
    through the currency option — previously unreachable, since no other language could name
    those currencies at all:

    import { toCurrency } from 'n2words/pt-BR'
    
    toCurrency(100,   { currency: 'JPY' })  // 'cem ienes'  → unchanged ✅
    toCurrency(42.50, { currency: 'JPY' })  // was 'quarenta e dois ienes e cinquenta sen'
                                            //   — a Portuguese-language "sen", invented
                                            //   → now RangeError

    This one is reachable on main today — pt-BR accepted a free-form currency and has real
    Japanese vocabulary, so the call above genuinely returns that string on 5.1.2. It is listed
    here rather than under Breaking changes for the same reason fix: reject fractional amounts for currencies with no minor unit, and unbreak the docs generator #430 shipped as a fix:: spelling
    a subunit that hasn't existed since 1953 is a defect, not behaviour to preserve. If you call
    pt-BR with a fractional JPY/KRW/VND/IRR/IDR amount, round first.

  • pt-BR can now name JPY ('cem ienes'), and the 30 languages in §1b now accept their own
    currency code explicitly instead of throwing.

  • Every currency-exporting language now exports currencyDefaults.currency and
    currencyValues.currency, so the supported set is machine-readable per language.

  • LANGUAGES.md gained a generated currency-coverage table.


How was this tested?

  • npm test762 tests passed vs. 453 on main (+309 — see the Verification
    subsection under The core change for the file-by-file breakdown; three
    brand-new gate files account for most of it).
  • npm run lint → clean (eslint + markdownlint)
  • Differential sweep against main in a scratch worktree: every language × every exported
    form × a value battery, plus 8 currency-option probes per currency language. All 1,176
    behavioral differences were triaged against main as it stood at abd02f2; every one falls
    into §1 above or into the guard fix: reject fractional amounts for currencies with no minor unit, and unbreak the docs generator #430 has since landed. The sweep is how §1a/§1b were found —
    neither was in the original commit footer. The language-layers work
    re-ran this exact sweep against main again on its own (same 1,176, nothing new), and
    separately re-ran a step-by-step version within the branch after each structural change; see
    its own verification section for both.
  • Release-notes rendergit-cliff --bump --unreleased run against the amended history
    to confirm the breaking section comes out complete (see below).

New gates specifically:

  • currency-vocab-contract.test.js — every code in a language's currencyValues.currency
    must have a matrix entry, every advertised currency's minor-unit rendering is exercised,
    every 1000-subunit currency survives the three-decimal round trip, and (added with the
    language-layers work) a gender-sensitive language's majorGender/minorGender fields are
    present on every entry, not just the one that happened to need it first.
  • bare-tag-contract.test.js — single-variant families must have an alias; aliases must be
    reference-identical to their target and point inside their own family; a locale profile
    (variantOf) is excluded from counting as a family's canonical variant, so a bare tag can't
    resolve to one.
  • variant-profile-contract.test.js — a locale profile's toCurrency with no options must
    match calling its base explicitly with the profile's own default currency, the specific trap
    a profile that regressed to a pure export * would fall into.

Docs audited against the source, not just re-read. Every structural and behavioral claim in
docs/bare-tag-aliases.md was re-derived by script (counts, family split, alias targets,
reference identity, file shape, form parity) and every rationale claim re-run (zh/sr/am
script pairs, pt-BR vs pt-PT scale systems, en-IN lakh/crore, es-* divergence). One
error found and fixed in 8dbba06: the doc credited es-MX and es-US with diverging from
es-ES in cardinal ceiling, but es-MX carries the same 10³⁰ ceiling — only es-US stops
lower, at 10²¹. Everything else in both currency and alias docs verified accurate.

Release-notes footer

Resolved. The BREAKING CHANGE: footer on the feat(core)! commit declares the three
cases above. Two problems were fixed there:

  1. It was silently truncated. The old footer's fourth line began zero-exponent: ja-JP (JPY), … — and the conventional-commit parser reads a line starting with word: as the
    start of a new footer. Everything from that line on was dropped. The generated
    BREAKING CHANGES section literally ended mid-sentence at "whose own currency is", with
    no warning from git-cliff or commitlint. Confirmed with a probe commit: blank lines and
    - bullets survive the parser; a line-initial token: does not.
  2. It under-declared the surface — only the zero-exponent guard and §1c were described.
    §1a and §1b were found by the differential sweep, after that footer was written.

The footer's first item described the zero-exponent guard. That shipped in #430 as a fix: and
is on main, so it has been dropped from the footer rather than declared twice; what remains of
it there is the cross-language reach noted under Non-breaking improvements.

Verified on the amended history: git-cliff --bumped-versionv6.0.0 (the ! in
feat(core)!: carries it; see below), and git-cliff --bump --unreleased renders the remaining
cases in full.

One thing this leaves open: if v5.1.3 is cut from main before this lands, #430's guard
reaches users in that release. If v6.0.0 is the next release instead, it carries both, and the
guard appears in the changelog under Bug Fixes rather than Breaking Changes.

One correction to something I said earlier in this PR's history: the footer is not what
triggers the major bump — the ! in feat(core)!: does that on its own. The footer only
supplies the description text. So the truncation was never going to ship a wrong version
number; it was going to ship an accurate 6.0.0 with a changelog that stopped mid-sentence.

Related Issue

📚 Adding a language or a breaking change? See CONTRIBUTING.md.

@forzagreen
forzagreen force-pushed the feat/currency-matrix-bare-tags branch from 7176de5 to 8883899 Compare August 15, 2026 18:07
@forzagreen forzagreen changed the title feat(core)!: validated cross-language currency matrix + bare-tag aliases feat(core)!: validated cross-language currency matrix, bare-tag aliases & language layers Aug 16, 2026
@forzagreen forzagreen changed the title feat(core)!: validated cross-language currency matrix, bare-tag aliases & language layers feat(core)!: separate currency from language, with a validated cross-language matrix Aug 18, 2026
@forzagreen
forzagreen marked this pull request as ready for review August 18, 2026 20:20
@TylerVigario

Copy link
Copy Markdown
Collaborator

Pushed proto/form-split — worth a diff.

A page that spells prices, one language one currency:

main    7,608 B
#428    9,033 B
branch  4,135 B

Less than half. Two reasons. Forms are separate modules, so importing toCurrency can't drag cardinals and ordinals along. And currencies are named exports rather than object properties, so you ship dollar, cent instead of all fifteen — Terser drops unused bindings, never unused properties. A second currency costs 81 bytes.

Same change collapsed the thirteen western English files into one. The and-flag as an option instead of variantOf inheritance: 1,014 comparisons against main, zero differences, en-CA included.

node bench/shape-compare.js builds all three and prints it.

Caveat: one language of fifty, gates fail, thirteen fixtures orphaned. Numbers to check, not code to merge.

@TylerVigario

Copy link
Copy Markdown
Collaborator

Separately — the parts I'd merge on sight: the zero-exponent guard, the three-decimal currency support, and the TypeScript 7 generator fix. The guard especially; toCurrency(0.99) returning "zero won" on 5.1.2 is real data loss and I reproduced it in four languages.

Only wish is they'd come in separately. At 191 files the good bits end up waiting on the parts still being argued about.

TylerVigario added a commit that referenced this pull request Aug 21, 2026
Local-only prototype, one commit against main.

  src/en-US/  toCardinal.js  toOrdinal.js  toCurrency.js
  src/en-GB/  toCardinal.js  toOrdinal.js  toCurrency.js
  src/en-CA/  toCardinal.js  toOrdinal.js  toCurrency.js
  src/lib/en/ core.js  ordinal.js  currencies.js   private, ./lib/* is null
  src/utils/check-currency.js

Sixteen English entry points had three numeral behaviours between them. Ten
of them -- en-AU, en-NZ, en-SG, en-ZA, en-KE, en-GH, en-IE, en-MY, en-NG,
en-PH -- were byte-identical to en-GB apart from which currency noun they
hardcoded, so they were currency wearing a filename. They are gone, and the
currency they held is a value you pass:

  import { toCurrency, AUD } from 'n2words/en-GB/toCurrency'
  toCurrency(42.50, { currency: AUD })

en-US, en-GB and en-CA stay as real language files because they genuinely
differ, and each keeps exactly the API it has on main -- en-US and en-CA
expose their cardinal options, en-GB doesn't, because British English isn't
optional about the "and". Their conventions are baked in rather than passed,
which is what keeps them fast: a flag reaching the shared builder as a
literal folds away, where an options object has to be resolved per call.

Forms are separate modules, so importing toCardinal cannot reach the ordinal
or currency code. Currency vocabularies are named exports, so a caller using
the default carries only its words.

Two harnesses, both reproducible:

  node bench/shape-compare.js   what a consumer downloads
  node bench/shape-perf.js      conversion speed, tinybench

Size, spelling an amount in one currency: main 7,608 B, #428 9,033 B, this
3,919 B. #428 ships fifteen English currency words to spell one; this ships
two, and a second currency costs 81 bytes. A site wanting all three forms
takes a combined bundle at 7,853 B, 245 B over main, so dist wants both
kinds and no consumer is worse off.

Speed is at parity with main across all three forms and all three languages,
within measurement noise -- en-GB cardinals run -6.4%, +5.6%, -0.1% at three
sizes.

Verified against main: 1,161 comparisons. The three surviving languages match
exactly including their option surfaces, and all ten deleted regions reproduce
via en-GB plus their currency. 0 differences.

Not addressed: en-IN, en-BD and en-PK use Indian lakh/crore grouping -- a
different scale table and segmentation, not a flag -- so they are a second
English grammar and keep their own files. Fixtures and the contract gates are
untouched and will fail; they enumerate src/*.js rather than walking
directories, and getInputKey collapses object-valued options to one key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfrA9rAkb8oHNcNHDaPVC4
@forzagreen

forzagreen commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Tyler — you're right about the split, and I've done it. Both are merged and this PR is rebased on them.

On proto/form-split

I rebuilt all three trees with your harness settings. Your numbers reproduce exactly — main 7,608, this PR 9,033, your branch 3,919 (your comment says 4,135; the commit message's 3,919 is the one that reproduces). So the disagreement is about what to do, not about the measurement.

What made me split it in two is where the saving actually comes from:

#428, all three forms, one file      9,033 B
#428, pruned to toCurrency           5,169 B   ← −3,864 B: per-form split
proto/form-split toCurrency          3,919 B   ← −1,250 B: named-export currencies
main, pruned to toCurrency           3,742 B

76% of it is the per-form split, which has nothing to do with how currency is represented. That's #431, and it's yours — it stands on main today without any of this PR. Measured off a real npm run build: en-US 7,778 → 4,293 / 3,815 / 3,827, so −45% / −51% / −51%.

With #431 in, the two branches land here:

form this PR + #431 proto/form-split gap
cardinal 4,291 B ~4,239 B 52 B
ordinal 3,815 B ~3,775 B 40 B
currency 5,252 B ~4,011 B 1,241 B

Two of three forms are within noise. The whole argument is one cell — and it isn't machinery. I stubbed the matrix down to a single currency, keeping this PR's exact code shape and string API: 4,069 B. So the option plumbing costs ~327 B against your ~177 B, and the other ~1,100 B is data — the 21 English currency vocabularies a caller didn't ask for.

Where I still disagree

You're right that CURRENCY_VOCAB[currency] plus Object.keys(...) pins the object, and that Terser drops bindings but never properties. That's real and I'm not arguing it.

But string-keyed selection and per-currency tree-shaking are mutually exclusive, and the string is doing work your benchmark doesn't measure. An invoicing app picks currency from data — row.currency is 'KES', not an identifier it can import. With named exports that needs a code → binding map, which pins everything again, so the advantage disappears in exactly the multi-currency case this PR exists to serve. Intl.NumberFormat(locale, { currency: 'KES' }) takes a string for the same reason.

One thing your numbers don't price: your branch deletes en-KE, en-AU and eight other public entry points that this PR keeps working as profiles. Some of the win is deleted API, and that's a separate decision with its own migration cost.

Where this leaves us

#430 and #431 are on main; this PR is rebased onto both and green. The fractional-amount
RangeError is no longer declared as a breaking change here — it shipped in #430 as a fix:,
so main already throws and re-declaring it would double-count. This PR's BREAKING CHANGE:
footer is down to the three currency-option cases.

Release plan: cut v5.1.3 from main first so the guard reaches everyone on ^5 without
waiting on this, then merge this and cut v6.0.0.

On the last 1,100 B

One correction to what I wrote above — I said "strings keep working", and that was too
generous to my own proposal. In a shape that actually tree-shakes, { currency: 'KES' } has to
stop working: a bare string as the option value is precisely what pins the table. Strings would
only survive one level out, at byCode['KES']. So that isn't a middle path, it's your design
with a string escape hatch, and I should have said so.

Which makes it worth measuring rather than arguing. What I'd want to see before committing
either way: the three bundle sizes, and what happens to currencyValues — the allowed-set enum
is what makes a typo'd 'KSE' throw RangeError listing valid codes, and it's documented as
introspectable API. With vocab objects there's no set to validate against, so that contract
changes shape and currency-vocab-contract.test.js goes with it.

If the numbers land near the 4,069 B floor and the enum survives in some form, I'll take it.
If preserving it costs the saving, I'd rather keep the string API and the validation and pay the
1,100 B. Either way this PR can land first — it's a non-breaking change afterwards.

Happy for you to own that follow-up if you'd prefer.

Introduces src/utils/currency-vocab.js: a per-language, ISO-4217-keyed
currency word-form matrix plus a zero-decimal-exponent guard
(assertCurrencyExponent), following the existing <form>Values enum
contract (resolveOptions) rather than adding new validation machinery.

Migrates pt-BR.js off its bespoke CURRENCIES map and dead
Intl.Locale.getCurrencies() auto-detect (verified non-functional on the
supported Node runtime) onto the shared module, closing the silent
fallback where an unmapped currency code printed the raw ISO code as a
word (test/fixtures/pt-BR.js's old CAD case).

Adds test/currency-vocab-contract.test.js: verifies every language's
currencyValues.currency enum has a matching currency-vocab.js entry, and
that zero-exponent currencies (JPY et al.) reject fractional amounts.

Adjusts options-contract.test.js's currency probe sample from 42.5 to a
whole number (42), since the enum round-trip check now needs a sample
valid for every declared currency, including zero-exponent ones that
reject a nonzero minor unit by design.
en-US, en-CA, and en-AU had byte-identical currency vocabulary
(DOLLAR/DOLLARS/CENT/CENTS) duplicated across three files despite naming
different ISO 4217 codes (USD/CAD/AUD) — collapsed onto shared word-form
data in currency-vocab.js. en-GB keeps its own GBP entry (pound/pence),
proving vocab-sharing and cardinal-grammar-sharing are orthogonal: en-AU
follows en-GB's British-style "and" grammar but en-US's dollar/cent words.

Each file gains a validated `currency` option (currencyValues, enum-gated
via the existing resolveOptions contract) alongside the existing `and`
option, replacing the previous single-currency-only behavior.
Both files hand-rolled identical EUR vocabulary (euro/euros/centime/
centimes) — collapsed onto a single shared frFR/frBE currency-vocab.js
entry pair (still separate exports, since either file naming an
additional currency later shouldn't affect the other). Adds the
validated `currency` option alongside the existing `and`/"et" option.
Each file already named a distinct currency (EUR/MXN/USD) with its own
hardcoded vocabulary — moved to currency-vocab.js as three separate
entries (esES/esMX/esUS) and given the validated `currency` option. The
"con" connector stays local: it's Spanish grammar, not currency data.
Sets up the es -> es-ES bare-tag alias cleanly.
…urrencies

Yen/won/dong have no everyday minor unit. ja-JP previously spelled a
fictitious, no-longer-circulating sen for any fractional yen input;
ko-KR and vi-VN silently discarded the fractional part entirely
(parseCurrencyValue's cents was never even read). Both are "well-formed
but wrong" — the exact bug class this project's contract system exists
to catch. All three now throw RangeError via assertCurrencyExponent,
"loud beats silent" like every other precondition guard in this codebase.

Each file gains its first-ever currency option (previously (value)-only)
so the shared currency-vocab contract applies uniformly. Removes ja-JP's
fixture cases that asserted the old fictitious-sen output.
Reference migration for the multi-form (3+) pluralization pattern used
by Slavic languages: currency-vocab.js holds the [singular, few, many]
word-form arrays, cs-CZ's own pluralize() keeps selecting the index —
grammar stays local, only the word data moved.
Same silent-drop bug as ko-KR/vi-VN: both destructured only `dollars`
from parseCurrencyValue, discarding any fractional rial/rupiah amount
without ever inspecting it. IRR and IDR added to CURRENCY_EXPONENTS;
both now throw RangeError instead. fa-IR also gains its first checkMax
call in toCurrency (previously the only guard-free form in the file —
UNBOUNDED makes it a no-op, but every other migrated form declares it).
Every migrated file destructures { major, minor } from CURRENCY_VOCAB,
and CurrencyWordForms.minor is string[] | null (a currency can have no
minor unit). TypeScript correctly can't see that minor[0]/minor[1] are
only ever reached inside an assertCurrencyExponent-guarded block, so
`npx tsc --project tsconfig.build.json` (part of `npm test`) failed
TS18047 on every affected file.

Casts minor to string[] only at the exact point of use (matching the
pattern already used in cs-CZ.js and pt-BR.js's `currencyWords.minor`
cast) rather than at the destructure site, so the null case stays
type-checked everywhere else in the function.
…SA,hbo-IL,he-IL,ka-GE): migrate to shared currency vocab
Thin re-export files (`export * from './en-US.js'` + an `aliasOf`
marker) so n2words/en, n2words/fr, n2words/ar, n2words/es resolve
without forcing a region subtag. No resolution/registry layer needed —
every gate already discovers "the language list" via readdirSync('./src'),
so a new file is picked up automatically.

Defaults: en -> en-US (most-used variant; en-CA/en-AU already proved
near-duplicate to it), fr -> fr-FR (fr-FR/fr-BE are currency-identical,
diverge only in cardinal grammar), ar -> ar-SA (only variant that
exists), es -> es-ES (accepted tradeoff: es-MX/es-US genuinely diverge
in currency and cardinal ceiling, unlike the fr pair).

No alias for zh/pt/sr/am: each has variants with genuinely different
scripts or default currencies, so region stays required.

currency-vocab-contract.test.js skips alias files (aliasOf defined) —
their currencyValues is a live re-export of the target's, not a
separate declaration to re-verify under a different name.
Bare-tag alias files (aliasOf exported) are re-exports, not their own
language — partitioned out of the main table/count/options generation
so "n2words supports N languages" can't inflate to 76 by counting
re-export shims. Adds a generated "Bare-tag aliases" section (alias ->
target, linked when the target has an options anchor) so the default
is documented and traceable to what the file actually does, not left
implicit.

Verified the new partitioning and section-rendering logic in isolation
(canonical/alias split against the real src/ directory, and the
markdown table rendering) — npm run docs:languages itself currently
can't run end-to-end on this branch because of a pre-existing, unrelated
break: package.json pins typescript@^7.0.2, and that release removed the
classic compiler API (ts.createProgram/ts.ScriptTarget) this script's
buildOptionsIndex() depends on entirely; the package now only exports
./lib/version.cjs plus a new, incompatible unstable/ast surface. This
predates this branch (reproduces identically on main) and is a separate
migration, out of scope here — `npm test`'s own type-check step is
unaffected since it shells out to the tsc CLI rather than the JS API.
A bare-tag alias (e.g. en.js) re-exports its target's forms via
export *, so it would otherwise pass add-language.js's "does this
file already have working forms" check and get mutated by
addFormsToExistingFile — corrupting a two-line re-export file. Detect
aliasOf and refuse with a message pointing at the real target file.
Adds docs/currency-vocab.md, mirroring range-contract.md's structure
(status, the fact, the gate, declaring, why this shape) — this
project's established template for an enforced contract.

Updates CLAUDE.md: currency-vocab.js added to the utils listing, a
currency options example alongside the existing gender example, the
self-contained-files principle now states its one deliberate
exception (currency word-data, not grammar), the new gate listed
alongside the other four, and bare-tag aliases noted in Quick
Reference and Project Structure.
Shows the currency option's new cross-currency capability (pt-BR
naming an amount in EUR, and an unsupported code throwing rather than
guessing), and documents the four bare-tag aliases in the Usage
section.
…piler API

typescript@7.0.2 removed the classic ts.createProgram/getTypeChecker API that
buildOptionsIndex() relied on to read JSDoc option types, which had made
`npm run docs:languages` impossible to run (pre-existing on main, unrelated
to this branch's other changes). Rewrite it against typescript/unstable/sync
(the tsgo-backed replacement API) and typescript/unstable/ast, reusing the
project's existing src/tsconfig.json so compiler options can't drift from
what CI's checkJs already enforces.

Regenerates LANGUAGES.md, which now correctly reflects this branch's currency
option additions and documents the new bare-tag aliases via the "Bare-tag
aliases" section generate-languages-md.js already knew how to render but
couldn't execute.
Every language whose variants aren't "very different" must have a bare-tag
entry point (see the design behind this in the upcoming docs/bare-tag-
aliases.md commit); previously only en/fr/ar/es had one. Since a family
with exactly one regional/script variant has no default to argue about, add
the same thin `export *` alias for all 42 other single-variant families.

Also adds a 'hbo' entry to LANGUAGE_NAME_OVERRIDES — Intl.DisplayNames
doesn't resolve the bare subtag (only 'hbo-IL'), so the alias's own display
name needed the same override treatment already used for the region-
qualified code.
New gate: every BCP 47 primary subtag with exactly one variant in src/ must
have a matching bare-tag alias file, and every alias's re-exported bindings
must be reference-identical to its target's. This is what makes "every
non-diverging language has one" durable rather than a one-time cleanup.

Since fidelity is now covered by an O(1) identity check, contract.test.js,
range-contract.test.js, and options-contract.test.js skip alias files —
re-running their fuzz suites against what's already proven to be the exact
same function object added no coverage, and was about to roughly double in
volume going from 4 aliases to 46.
…guages

lang:add now refuses a bare (no region/script subtag) code outright — that
namespace is reserved for alias files. When the code being scaffolded is
the first variant in its family, its bare-tag alias is created alongside it
automatically, keeping the completeness gate self-satisfying for new
additions. When it joins an existing family, nothing is automated (repointing
or dropping an existing default is a human judgment call) — the tool just
prints a note pointing at docs/bare-tag-aliases.md.
The headline and main table previously counted and listed all 72 regional/
script variants as if each were its own language. Restructure the generator:
the headline now reports family count (50 languages, 72 variants), and a
new "Languages" table groups variants under their family with the bare-tag
Entry point column as the primary column — subsuming the old standalone
"Bare-tag aliases" table. The existing flat per-variant table (ceilings,
options) moves to "All Regional Variants" as the detail reference.

Add docs/bare-tag-aliases.md: the full "very different" rule (script
divergence or a fundamentally different core numbering system — not
vocabulary, not currency) with the worked zh/sr/am/pt evidence, and how the
completeness/fidelity gate and lang:add's auto-scaffold keep it durable.
README/CLAUDE.md examples and prose previously led with region-qualified
codes (n2words/en-US) and mentioned bare tags as a secondary convenience.
Flip that: bare-tag entry points are now the lead examples and the stated
default way to import a language, with region-qualified codes explained as
what to reach for when you need a specific variant or when a language has
no single default. Headline language counts (README tagline, package.json
description) drop from "70+" to "50+" to match family count, not variant
count.
…ucture

Seven currency implementations cast `minor` to `string[]` immediately after
destructuring it, asserting non-null where nothing had yet proven it. In
fil-PH the cast was an outright dereference (`minor[0]`) at function top,
outside any `sentimos > 0n` guard — so a currency with `minor: null` would
crash with TypeError on a *whole* amount, where assertCurrencyExponent never
fires, violating the conversion contract's "well-formed string or RangeError"
promise.

Move each narrow inside the branch that has already established a nonzero
minor-unit amount, which is where assertCurrencyExponent's guarantee actually
holds. This is the rule CLAUDE.md's Options Pattern states; these files
predated it or regressed past it.
…mily

The gate checked that an alias's bindings were reference-identical to
whatever `aliasOf` named, but never that `aliasOf` named a variant of the
same language. `src/en.js` re-exporting `./de-DE.js` passed every assertion:
completeness skips multi-variant families like en, and fidelity only compares
against the declared target. `n2words/en` would have returned German, with
LANGUAGES.md rendering English's entry point as a link into German's section.
…presents

assertCurrencyExponent's doc claimed it guards any fractional amount a
currency can't represent, but it reads `cents` — which parseCurrencyValue has
already truncated to two decimal digits, so `toCurrency('1.004')` reached JPY
as a whole yen and spelled it. Two decimals is the right precision and is now
documented as the contract rather than left implicit: it is at least as fine
as every supported currency's minor unit, so nothing representable is lost,
and coarser input has to be tolerated because `0.1 + 0.2` is
`0.30000000000000004`.

CURRENCY_EXPONENTS was typed `Record<string, number>` and documented as
holding any exponent diverging from 2, but only 0 was ever honored. A
contributor adding `KWD: 3` (fils are 1/1000) would have got
`toCurrency('1.500', { currency: 'KWD' })` spelling fifty fils instead of five
hundred, silently, past every gate. Type and document it as zero-only, with
the parser precision as the stated reason, and drop the `?? 2` fallback that
implied otherwise.
Multi-currency support was verified only at a whole amount: options-contract
round-trips each declared `currency` enum value at 42, and the zero-exponent
probe expects a throw. So a language's non-default currencies never reached
the `cents > 0n` branch, and a `minor` array too short for that language's own
pluralization would emit "...e cinquenta undefined" with only a hand-written
fixture to catch it — which most languages don't have per currency.

Sweep every non-zero-exponent currency a language advertises at fractional
amounts and assert the result is well-formed. Broaden the zero-exponent probe
from the single 1.5 sample to the shapes per-language fixtures used to pin
before those currencies started throwing (bare fraction, minimum minor unit,
whole+fraction, negative) — ja-JP's deleted sen cases are the case in point.

Add two structural invariants the language files silently depend on:
CURRENCY_EXPONENTS holds only 0, and a currency has minor-unit words exactly
when its exponent is nonzero. The latter is what actually licenses narrowing
`minor` to `string[]` inside a `cents > 0n` branch, in all 73 files that do it.

isWellFormed moves to test/helpers/value-utils.js so "well-formed" means one
thing across the gates that assert output shape.
The note under the family table says `(default)` marks the chosen variant
"where a family has more than one variant", but the marker was appended
unconditionally whenever a variant matched its entry point — so 43 of the 46
aliased rows advertised a default among exactly one choice, contradicting the
sentence directly below them. It now appears only on en, es and fr, the three
families where there is something to be default among.
…ointer

docs/bare-tag-aliases.md sent readers to CLAUDE.md's Quick Reference for "the
current list" of aliases, but that bullet names only the four families that
*don't* have one. Point at LANGUAGES.md's generated `Entry point` column,
which is the actual list.

"50+ languages" in the npm description and README headline was both a
discovery regression against the previous release's "70+" and wrong by one —
the count is exactly 50. Say "50 languages, 72 regional variants", which
states the recount honestly instead of understating it.
dist/ carries a full copy of each aliased language under both names, which
reads like waste until you ask what a re-export stub would do: dist/en.js
would need a second fetch of dist/en-US.js, and the UMD build has no module
loader to follow that redirect at all. The UMD alias bundles aren't even
byte-equal to their targets — they expose the global the README documents
(n2words.en, not n2words.enUS). Record the reasoning so the next reader
doesn't re-litigate it.
@forzagreen
forzagreen force-pushed the feat/currency-matrix-bare-tags branch from 1ac1fde to a25870c Compare August 21, 2026 20:41
Declares the breaking surface of this branch in one place. Every change is
already committed; nothing here alters code.

The `!` in the type prefix is what triggers the major bump. The footer below
supplies the text git-cliff renders under "BREAKING CHANGES" in the release
notes. Keep every footer line from starting with a `word:` token — the
conventional parser reads such a line as the start of a new footer and
silently truncates the note there, mid-sentence, with no warning.

BREAKING CHANGE: `toCurrency`'s `currency` option changes in three ways. Whole
amounts in a language's own currency are unaffected everywhere, and `toCardinal`
and `toOrdinal` are untouched. The zero-subunit guard that an earlier revision of
this footer declared here shipped separately as a fix and is not re-declared.

1. The `currency` option is now validated in the 41 languages that previously
declared `toCurrency(value)` with no options parameter and so discarded it
silently. Passing `{ currency: 'USD' }` to hi-IN returned `पाँच रुपये` ("five
rupees") on 5.1.2 and now throws RangeError naming the accepted set. Affected
languages — am-ET, am-Latn-ET, ar-SA, az-AZ, bn-BD, cs-CZ, da-DK, el-GR,
fa-IR, fi-FI, fil-PH, gu-IN, ha-NG, hbo-IL, he-IL, hi-IN, hr-HR, hu-HU, id-ID,
ja-JP, ka-GE, kn-IN, ko-KR, lt-LT, lv-LV, mr-IN, ms-MY, nb-NO, pa-IN, pl-PL,
ro-RO, sv-SE, sw-KE, ta-IN, te-IN, th-TH, tr-TR, uk-UA, ur-PK, vi-VN, yo-NG.

2. In the 30 languages that already took an options object, an unsupported
`currency` value changes error type from TypeError to RangeError, because
`currency` is now a known key and an out-of-set value is a range problem
rather than a type problem. Callers matching on `instanceof TypeError` must
catch RangeError too. Affected languages — de-DE, en-AU, en-BD, en-CA, en-GB,
en-GH, en-IE, en-IN, en-KE, en-MY, en-NG, en-NZ, en-PH, en-PK, en-SG, en-US,
en-ZA, es-ES, es-MX, es-US, fr-BE, fr-FR, it-IT, nl-NL, pt-PT, ru-RU,
sr-Cyrl-RS, sr-Latn-RS, zh-Hans-CN, zh-Hant-TW. Passing a language's own code
now succeeds where it used to throw, so `toCurrency(5, { currency: 'USD' })`
on en-US returns "five dollars".

3. pt-BR's `currency` option changed from a free-form string to a validated
enum, so three previously-accepted inputs now throw RangeError. `{ currency:
'' }` was the previous documented default and auto-detected BRL; the default is
now the literal `BRL`. `{ currency: 'brl' }` worked because lowercase codes
were uppercased. Any code pt-BR has no words for, such as `{ currency: 'CAD' }`,
was a documented fallback that spelled the bare ISO code, e.g. "cinco CAD".
The currencies pt-BR can name are listed in its exported
`currencyValues.currency`.
docs/ holds hand-written contract docs (bare-tag-aliases.md,
currency-vocab.md, options-contract.md, range-contract.md), so it can't simply
be ignored wholesale — but a JSDoc run pointed at it drops ~60 generated pages
right next to them, one `git add -A` away from being committed.

That had already happened locally: the working tree carried a full JSDoc 4.0.5
render of the pre-v5 architecture (i18n/ar.js, classes/abstract-language.js,
`export default class extends BaseLanguage`) — a source layout that hasn't
existed for three majors. Those files were never tracked on any branch, and
nothing in the repo generates them: there is no jsdoc dependency and no script
for it (eslint-plugin-jsdoc is a linter). They're removed, and the patterns
below keep a future ad-hoc run from re-staging the same debris.
The currency matrix is keyed by language — one named export per language in
src/utils/currency-vocab.js — because keying it by currency would defeat the
per-language tree-shaking the bundles depend on (docs/currency-vocab.md has
the full constraint). That shape is right for the build and useless for
answering "which languages can name EUR?", which until now meant reading all
72 exports or grepping. The per-language `currency` enums in LANGUAGES.md's
Language Options section are the same data, but spread across 72 tables.

Add the inverse view as a generated section: one row per ISO code, splitting
the languages it is the default for from the ones that merely name it. That
split is the part worth seeing — the first column is essentially each
language's home currency, the second is the cross-currency capability the
matrix exists to enable, and today it holds exactly one language (pt-BR).

Built from each language's exported `currencyValues.currency` rather than from
the vocab module, since the enum is what a caller can actually pass;
currency-vocab-contract.test.js already pins the two together, so either
source gives the same answer. Generated, so it can't drift: 42 currencies, 76
language/currency pairs, verified against an independent read of the vocab
module.
Records the two reasons a currency can lack minor-unit words, since the
distinction decides whether a contributor can just add vocabulary or has
to change the parser first.

The 1000-subunit family (TND millimes; KWD/BHD/OMR/JOD/IQD/LYD fils) is
blocked: parseCurrencyValue tracks exactly two decimal digits, so 1.500
dinars arrives as 50 minor units and would be spelled as fifty millimes
rather than five hundred. CURRENCY_EXPONENTS being typed Record<string, 0>
already stops anyone adding TND: 3 by accident, but nothing said what the
unblocking work actually is.

The ordinary case needs only vocabulary. Called out there that minor: null
is not a placeholder for an untranslated word — the gate enforces
minor-words if and only if nonzero exponent, so the honest way to say "no
word yet" is to leave the currency out of that export.
The "multi-variant families that still get an entry point" section credited
es-MX and es-US jointly with diverging from es-ES in both default currency
and cardinal ceiling. Only the currency claim holds for both: es-MX carries
the same 10^30 ceiling as es-ES, and es-US is the one that stops lower, at
10^21. Splits the two claims and adds the concrete lakh/crore rendering
that makes the en-IN example checkable.
parseCurrencyValue tracked exactly two decimal digits, which ruled out every
currency dividing into 1000 — TND (millimes), KWD/BHD/JOD/IQD (fils), OMR
(baisa) and LYD (dirham). Naming one at two digits is not a partial answer
but a wrong one: 1.500 dinars would arrive as 50 minor units and be spelled
"one dinar and fifty millimes", well-formed and confidently off by a factor
of ten.

parseCurrencyValue now takes the digit count to keep, defaulting to 2 so
every existing call is unchanged. CURRENCY_EXPONENTS gains 3 alongside 0,
and minorUnitDigits derives the argument from it. Deriving it rather than
hard-coding is what keeps the two in step, and the map deliberately still
reports 2 for a zero-exponent currency like JPY: parsing those at 0 digits
would silently turn 1.5 yen into 1 yen, which is the truncation
assertCurrencyExponent exists to refuse.

The digit count depends on the resolved currency, so the four languages
naming these codes resolve options before parsing rather than after. en-US
carries the full set (English is where these are quoted internationally, and
it is the bare n2words/en entry point), fr-FR and fr-BE carry TND for
francophone Tunisia, and ar-SA carries all seven.

ar-SA also needed a grammar fix the 100-subunit currencies never exercised.
It hardcoded the minor unit as feminine, correct for هللة but wrong for
مليم, فلس and درهم, and Arabic inverts numeral gender for 3-10 — so 3 fils
rendered ثلاث فلوس instead of ثلاثة فلوس. MINOR_GENDER now drives that
selection per currency. SAR output is unchanged.

The gate proves the round trip behaviourally: for every 3-decimal currency a
language advertises, 1.500 and 1.050 must not render alike, which they do
the moment a language forgets to pass the digit count.
The TODO section described the two-decimal parser as a blocker and told
contributors these currencies stay out of the matrix. Both are now false, so
it documents the contract instead: which currencies carry exponent 3, that a
language naming one must resolve options before parsing and pass
minorUnitDigits, and why that helper reports 2 rather than 0 for JPY.

Adds the grammar consequence, which is the part a contributor will actually
trip over: a minor amount now arrives as 0-999 rather than 0-99, and in
Arabic the wider range surfaces gender agreement that 100-subunit currencies
never reached. Points at ar-SA MINOR_GENDER as the example of why that
selection is per-language grammar and not matrix data.

Keeps the "missing minor-unit words" half unchanged — it was accurate, and
the gate still makes minor: null unusable as a placeholder.
MAD divides into 100 santim, so unlike the dinars it sits beside in the
matrix it is an ordinary 2-decimal currency: no CURRENCY_EXPONENTS entry,
parsed at the default precision. Called that out at both definition sites,
since landing it right after the 1000-subunit work invites the assumption
that every Arab-world currency is a thousandth.

درهم is reused rather than duplicated — it is Libyas minor unit and
Moroccos major one, the same word in two roles. Its Arabic minor unit
سنتيم is masculine, so MINOR_GENDER carries it and 3 santim renders
ثلاثة سنتيمات rather than the feminine ثلاث.

LANGUAGES.md regenerated: 50 distinct currencies across 96 pairs.
…to three layers

A BCP 47 code like en-KE encoded three independent facts at once: which
numeral grammar, which currencies are nameable, and which currency is the
default. Measuring the 16 English variants showed only 3 distinct numeral
behaviours behind them — the other 13 files differed only in default
currency, and en-AU vs en-SG's numeral code was a byte-for-byte clone,
diverging only in doc comments. Name each fact for what it actually varies
by instead: see docs/language-layers.md for the model in full.

Currency words move from being keyed per *locale* to per *language* in
src/utils/currency-vocab.js (enUS/enGB/enCA/... merge into one `en`; es
similarly; ptBR/ptPT and the zh/sr/am script splits stay separate, since
they diverge in wording or grammar the way docs/bare-tag-aliases.md's
"very different" test already excludes from a bare-tag alias). Any English
entry point can now name any currency the language has words for — en-GB
can quote KES, en-KE can quote GBP — where before each locale only knew its
own. Each `<form>Values` union is now `keyof typeof <language>` instead of
a hand-typed literal, so widening a language's map widens every file that
references it in the same edit.

14 of the 16 English locale files (and Spanish's es-MX) turned out to be
behavioural clones of a base under default options — en-AU, en-BD, en-GH,
en-IE, en-KE, en-MY, en-NG, en-NZ, en-PH, en-PK, en-SG, en-ZA, es-MX. Those
become ~40-line locale profiles: `export *` from their base plus a
`toCurrency` wrapper applying just their own default currency (variantOf).
en-CA stays a full implementation — it turned out to expose a
`hundredPairing` cardinal option no other English variant has, invisible to
a default-value probe. Collapsing removes roughly 6,400 lines of duplicated
numeral logic.

Widening currency reachability surfaced two real bugs, both fixed here
rather than shipped:

- Every gender-sensitive language (ru, uk, pl, hr, lt, lv, ro, sr, es, ar)
  hardcoded its single currency's grammatical gender as a literal in
  toCurrency. That was already wrong the moment a second currency became
  reachable — ru naming a feminine-noun currency alongside masculine
  рубль would have rendered "один" instead of "одна" — but had no way to
  fire while each language named only one currency. majorGender/minorGender
  move into the matrix alongside the word forms they describe; consumers
  read the field instead of hardcoding it. ar's own MINOR_GENDER table
  folds into this.
- en-GB/en-IN/en-CA (and originally en-US) indexed major[1]/minor[1] for
  any plural amount, unconditionally. Some currencies in the merged `en`
  matrix have an invariable noun (taka, ringgit, naira, rand) — a
  single-element array — so a plural amount produced "...and fifty
  undefined". Indexing now checks array length before assuming a plural
  form exists.

Non-breaking and output-neutral: every existing import specifier, every
default currency, and every toCardinal/toOrdinal/toCurrency(defaults)
output is unchanged, verified by an exhaustive differential probe (~1,300
values x 118 codes) run after every structural step, not just at the end.
Widening a `currencyValues` enum and adding `variantOf` are both additive.

New gate: variant-profile-contract.test.js proves a profile's toCurrency
with no options matches calling its base explicitly with the profile's own
default currency — the specific trap a profile that regressed to a pure
`export *` would fall into, since the base's toCurrency closes over the
base's own default. bare-tag-contract.test.js and
currency-vocab-contract.test.js are updated so a bare tag never resolves to
a profile and the vocab-key lookup follows the new per-language convention
(with a small override list for the languages that stay split).

LANGUAGES.md's currency coverage table now collapses a family's "also
names it" column to its primary subtag whenever every variant reaches a
currency together, rather than repeating the same per-language fact once
per locale.
A table alone didn't make the three-layer split easy to see at a
glance; a diagram of en-GB/en-AU/en-KE sharing one currency-vocab
matrix makes the capability unlock (either can now name the other's
currency) visible in a way the prose only explains.
The old diagram drew the file graph rather than the idea, and buried the
point it was meant to make. Reorder around the three layers in sequence,
and state the cardinality argument outright: across 16 English entry
points the layers carry 4, 1 and 16 distinct values, so one filename
never could have encoded all three without duplicating the numerals.

Add the reachability grid (diagonal before, full after) and the Arabic
example, where layer 2 alone unlocks nine currencies from one entry
point with no extra files.
The Language Options preamble showed only toCardinal and toCurrency,
omitting toOrdinal even though three languages take ordinal options, and
never said an option belongs to exactly one form. That left the Form
column doing the disambiguating work unannounced, and made it look like
`currency` might be accepted by toCardinal -- it isn't, and passing it
throws TypeError.
Each currency-vocab export carried `@type {Record<string,
CurrencyWordForms>}`, which widened its keys, so every
`XxCurrency = keyof typeof xx` resolved to `string`. The per-language
currency unions were inert: `toCurrency(1, { currency: 'XXX' })`
typechecked cleanly for consumers, and only the runtime RangeError
caught it. Verified against a packed tarball, not just in-repo.

`@satisfies` validates each entry against CurrencyWordForms without
widening, so the keys stay literal and the derived unions are real.

es-ES/es-US compared `majorGender === 'feminine'` directly against the
matrix; with literal types every es currency is masculine, so TS
flagged the comparison as dead. They now use the same narrowing-cast
idiom the Slavic files already use, keeping the branch live for the
first feminine es currency.
The 'Also names it' column collapsed a family to its primary subtag only when
the family had more than one variant. That gated on the wrong fact — what the
cell needs is a specifier a reader can import, which is the bare-tag alias.

Two consequences, in opposite directions:

- A single-variant family never collapsed, so Arabic printed 'ar-SA' for MAD,
  TND, BHD, ... even though 'ar' exists (bare-tag-contract.test.js requires an
  alias for exactly this case) and is the documented primary entry point.
- The four alias-less families collapsed anyway when every variant happened to
  reach a currency: EUR listed 'pt', and n2words/pt resolves to a file that
  does not exist.

Gate on aliases.has(primary) instead. Also regenerates the option Type column,
which was still stale from 0a48f9d — with the matrix keys no longer widened to
string, 'currency' now documents its real per-language union.
README said the currency option is validated and throws on an unsupported
code, but never said where to read the accepted set — a caller building a
picker had no way to discover currencyValues short of catching RangeErrors.
It was only written down in contributor-facing docs (CLAUDE.md, currency-
vocab.md) and one clause of LANGUAGES.md.

Also states the general rule, including its exception: every options-taking
form exports <form>Defaults, but <form>Values exists only for fixed-set
options — 16 forms take boolean-only options and have no Values export.
@forzagreen
forzagreen force-pushed the feat/currency-matrix-bare-tags branch from a25870c to 05299d1 Compare August 21, 2026 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants