feat(core)!: separate currency from language, with a validated cross-language matrix - #428
feat(core)!: separate currency from language, with a validated cross-language matrix#428forzagreen wants to merge 47 commits into
Conversation
7176de5 to
8883899
Compare
|
Pushed A page that spells prices, one language one currency: Less than half. Two reasons. Forms are separate modules, so importing Same change collapsed the thirteen western English files into one. The and-flag as an option instead of
Caveat: one language of fifty, gates fail, thirteen fixtures orphaned. Numbers to check, not code to merge. |
|
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; Only wish is they'd come in separately. At 191 files the good bits end up waiting on the parts still being argued about. |
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
|
Tyler — you're right about the split, and I've done it. Both are merged and this PR is rebased on them.
On
|
| 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).
…G,en-ZA): migrate to shared currency vocab
…to shared currency vocab
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
…to shared currency vocab
…igrate 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.
1ac1fde to
a25870c
Compare
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.
a25870c to
05299d1
Compare
Pull Request
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-GBfor Kenyan shillings was impossible, even though "forty-twoshillings and fifty cents" is plain English. You had to import
en-KE— a 450-line duplicate ofen-GBwhose 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:
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'scurrencyoption becomes a real validated enum, with its allowed setderived from the matrix rather than hand-typed. On
mainit was honored by exactly one of72 languages; the other 71 ignored or rejected it.
en-AU,en-BD,en-GH,en-IE,en-KE,en-MY,en-NG,en-NZ,en-PH,en-PK,en-SG,en-ZAandes-MXwere behavioural clones differing onlyin default currency; they become ~40-line locale profiles. ~6,400 lines deleted.
en-CAlooked like a clone too and isn't — see 14 clone files collapse into
locale profiles, below.
language can name a second currency: a grammatical-gender mismatch across every
Slavic/Baltic/Romanian/Arabic/Spanish language, and an
undefined-rendering bug forcurrencies whose noun doesn't pluralize (taka, ringgit, naira, rand).
two-decimal precision previously ruled out TND, KWD, BHD, OMR, JOD, IQD and LYD entirely.
import … from 'n2words/de'the primary import path.zh,pt,sr,amstay region/script-qualified only.counted variants as languages).
v6.0.0) — one break, in three cases, all consequences ofthe
currencyoption becoming real. Full before/after underdown. The fractional-amount
RangeErrorthat an earlier revision of this description listed asa 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 theend. This section is the evidence for the summary above.
The three layers
A BCP 47 code like
en-KEwas encoding three unrelated facts in one string. They don't varytogether — 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"] endThe 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.
src/{code}.jsimplementationsrc/utils/currency-vocab.js, keyed by languageen, 24 currencies)src/{code}.jswithvariantOfThe measurement that started this
Running every English variant over ~1,300 probe values and grouping by identical output:
toCardinal/toOrdinal)en-USmain)en-AUvsen-SGdiffered by zero lines of logic — the entire diff between the two fileswas doc comments. The file count was tracking currency, not language.
(Layer 1 ends up with 4 implementations rather than 3 because
en-CAexposes an option theothers 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:
en-US,en-GB,en-IN,en-CAen— 24 currencieses-ES,es-USes— 3es-MX)fr-FR,fr-BEfr— 3ar-SAar— 9pt-BR,pt-PTptBR/ptPT— kept splitArabic 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:
Arabic numerals don't change between Riyadh and Casablanca, so there is no
ar-MAfile and noreason for one — the currency was never a property of the language. Portuguese is the
counterexample that proves layer 2 is keyed correctly:
pt-BRandpt-PTname the same EURcent with genuinely different words (
centavovscêntimo), so their matrices stay split, asdo the
zh/sr/amscript pairs — the same setdocs/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"onmain: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>Valuescurrency union iskeyof typeof <language>instead of a hand-typed literal(
EnCurrency,EsCurrency, …), so widening a language's map widens every file referencing itin 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, andes-MXturned 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 plusa
toCurrencywrapper applying just its own default:en-CAlooked like the same clone by every default-value probe — until a second check (doesthis 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-CAstays a full implementation; everything elselisted 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-*, andar-SAeach hardcoded their single currency's noungender as a literal in
toCurrency(integerToWords(rubles, 'masculine')). That was alreadywrong the instant a second, differently-gendered currency became nameable:
majorGender/minorGendermove into the matrix next to the word forms they describe;ar-SA'sown
MINOR_GENDERtable folds into this and is deleted. New gate:currency-vocab-contract.test.jsnow checks each gender field independently — if any entry fora language declares
majorGender, every entry must (same forminorGenderand any entry with aminor 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), andrand(ZAR) don't pluralize — a one-element word-form array, not the usual[singular, plural]pair. The English bases indexedmajor[1]for any count past 1unconditionally:
Fixed by checking array length before assuming a plural form exists
(
count === 1n || major.length < 2 ? major[0] : major[1]), applied inen-US,en-GB,en-IN, anden-CA— the four files with real currency-building logic; every profiledelegating to one of the first three inherits the fix automatically.
The trap a naive collapse would have shipped
A profile that only shadowed
currencyDefaultsviaexport *, without thetoCurrencywrapper, would be silently wrong: the base's
toCurrencycloses over the base's ownmodule-scope
currencyDefaults, soen-AUwould reportcurrencyDefaults.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: forevery profile, it asserts
profile.toCurrency(v)with no options equalsbase.toCurrency(v, { currency: profileDefault })called explicitly, across a probe setincluding 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 afamily's "canonical" variant, same as an alias (
aliasOf) already was, so a bare tag cannever 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 fromthe code's primary subtag (
en-KE→en) instead of the old full-locale camelCase(
enKE), with a small override list for the languages that stay split(
pt-BR→ptBR,zh-Hans-CN→zhHans, ...). Profiles are skipped for the same reasonaliases already were — their
currencyValuesis a re-export, not a separate declaration.LANGUAGES.md's currency table, before it would have gotten worseWidening
en's reachability turns "72 variants name 50 currencies across 96 pairs" into462 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 afamily's contribution to its bare primary subtag whenever every variant reaches a currency
together:
Bundle-size honesty
Widening
en's matrix has a real, measured cost — every English bundle now carries everycurrency any English locale names, not just its own. Built
dist/with rollup to measure itrather than estimate, against
maindirectly (abd02f2) — this is the number that mattersto 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 belowis unchanged, since #430 touches only the five zero-exponent languages and #431 adds files
without altering the combined bundles:
mainen-US.jsen-GB.jsen-AU.js(profile)en-KE.js(profile)es-ES.jses-MX.js(profile)mainhas nocurrency-vocab.jsat all —toCurrencyonmainhardcodes its own words inline— so this delta is the full cost of gaining a validated, cross-currency-capable
currencyoption, 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 farless — rekeying the matrix from locale to language, alone:
9503e01)en-US.jsen-GB.jsen-AU.js(profile)es-ES.jsNote
en-AU.jsanden-KE.jsland at the same 8,974 B in themain-relative table — bothprofiles bundle the identical
enmatrix, differing only in which default-currency string isembedded. A profile's bundle is not smaller than its base's either way —
rollup.config.jsproduces 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 bylanguage, 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
maindirectly (1fe4e02, after #430 and #431 merged) — is any of this breaking,full stop:
toCardinal/toOrdinal: 0 differences, every one ofmain's 72 codes, ~1,300 probevalues each.
toCurrencyunder default options: 0 diffs. An earlier revision reported 40, all in thefive zero-exponent languages; fix: reject fractional amounts for currencies with no minor unit, and unbreak the docs generator #430 landed that guard on
main, somainand this branch nowagree. Re-measured after the rebase, not carried over: 90 comparisons across
ja-JP,ko-KR,vi-VN,fa-IR,id-ID, zero differences.toCurrencyunder thecurrencyoption: exactly the 1,136 diffs already declared in §1below — 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.
main's absence of the featureentirely (
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.toCurrencyunder each entry point's own default options: 0 differences at every step. Theonly diffs across the whole sequence are the 13 new
variantOfmetadata lines the profilesintroduce — not observable through any public function.
en-CA/hundredPairingand invariable-noun bugs: adiff against
mainalone wouldn't isolate which of this PR's several structural stepsintroduced a given regression, so the granular replay is what made them findable at all.
npm test→ 762 tests passed on this branch, vs. 453 onmain(+309) — measured on both,not estimated:
maincontract.test.jsconversions.test.jsoptions-contract.test.jsrange-contract.test.jstest/utils/*.test.js(5 files)bare-tag-contract.test.jscurrency-vocab-contract.test.jsvariant-profile-contract.test.jsThe three new files are the three new gates this PR adds;
conversions.test.jsandoptions-contract.test.jsgrew because every currency-exporting language now has realcurrency-option coveragemainnever had. Isolated one level further (just the language-layersstep, measured the same way against the pre-layering commit
9503e01within this branch, thenumber 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.jsalone: 152 → 129), offset by the +14 newvariant-profile-contract.test.jsadds. That −9 is real but it's a statement about one stepwithin this PR, not about this PR versus
main— the table above is the number that matters tosomeone deciding whether to merge this.
npm run lintclean on both.Every before/after below was produced by running the actual code on
main(1fe4e02, currentas 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 thefeat(core)!commit so they reach the generated release notes — see Release-notes footer atthe bottom for a parser bug that was truncating that footer.
The fractional-amount
RangeErrorfor zero-exponent currencies (JPY, KRW, VND, IRR, IDR) waslisted here as a second break in an earlier revision. It shipped in #430 as a
fix:and isalready on
main, so this PR no longer introduces it and it is not re-declared. What this PRstill adds is the cross-language reach of that same guard — see Non-breaking improvements
below.
At a glance
currencyoption silently ignoredRangeErrorcurrencyoption unknownTypeErrorRangeError(or now works, if it's the language's own currency)currencyfree-form → enumRangeError1.
currencyis now a validated enumOn
mainthecurrencyoption was honored by exactly one language (pt-BR). The other 71either 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
RangeErrorOn
mainthese declaredfunction toCurrency(value)— no options parameter at all — so JSdiscarded the second argument without a word. You got your local currency back and no
indication that your request was dropped:
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-NGThis 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— nowRangeError, or succeedThese already took an options object (for
and/formal), so an unknowncurrencykey wasa
TypeError. Two outcomes now, depending on the code you pass:The only genuinely breaking part here is the error type:
TypeError→RangeError. Thisis deliberate and consistent with the rest of the options contract —
resolveOptionsthrowsTypeErrorfor a malformed option (unknown key, wrong type) andRangeErrorfor awell-formed but out-of-set value.
currencyis now a known key, so an unsupported value isa 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-TW1c. pt-BR: free-form string → validated enum
pt-BR is the one language where
currencyactually worked, so it's the one with realmigration work. Three previously-accepted inputs now throw:
That last one is the reason the enum exists:
mainwould cheerfully emit'um XYZ'for anythree letters you handed it, mixing an ISO code into spelled-out Portuguese.
Still works, unchanged — the currencies pt-BR genuinely has words for:
The authoritative set is exported:
currencyValues.currencyis['BRL', 'USD', 'EUR', 'GBP', 'JPY'].Migrating (all of §1)
The allowed set is introspectable at runtime, so you never have to guess:
{ currency: '' }→ drop the option, or pass the explicit code..toUpperCase()it.TypeErroraroundtoCurrency→ catchRangeErrortoo (or justError).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, soevery 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:
Now supported: TND (millime), KWD/BHD/JOD/IQD (fils), OMR (baisa), LYD (dirham).
How it works
CURRENCY_EXPONENTSnow holds3alongside0(2 stays the implicit default for anythingabsent), and
minorUnitDigits(currency)derives the parser argument from it. Because the digitcount depends on the resolved currency, a language naming one of these resolves options
before parsing:
minorUnitDigitsdeliberately 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
assertCurrencyExponentexists to refuse.Coverage
Arabic number words are variant-independent while currency is country-specific, so
arSAisthe single home for the whole Arab-world set.
en-US(so alson2words/en)fr-FR,fr-BEar-SAMAD 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_EXPONENTSentry, parsed at thedefault precision. Flagged at both definition sites, since landing it beside the 1000-subunit
work invites exactly the wrong assumption.
درهم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_GENDERcarriesit 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-SAhardcoded 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:
A new
MINOR_GENDERmap drives the selection per currency. This is grammar, so it lives inar-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.jsproves the round trip behaviorally rather than trusting thedeclaration: for every 3-decimal currency a language advertises,
'1.500'and'1.050'mustnot 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-GBand every otherregion/script-qualified import keeps working exactly as before.
What ships
46 alias files, one per language that can safely have a default variant:
No
package.jsonchange was needed: the exports map is already a wildcard(
"./*"→"./src/*.js"), son2words/deresolves the momentsrc/de.jsexists. Types comealong the same way via
typesVersions../utils/*stays walled off (null).The 50 languages break down as:
en(16 variants),es(3),fr(2)en→en-US,es→es-ES,fr→fr-FRzh,sr,am,pt43 + 3 = 46 aliases, 72 variant files, 50 languages.
The rule: what makes a family "very different"
The interesting design question is why
engets an alias when its variants disagree wildly,while
pt— only two variants — doesn't. The test is deliberately narrow: would defaultingsilently 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:
For
zh, 4 of 7 sampled values render byte-identically; the pairs diverge only where acharacter has a distinct traditional form (
万/萬,亿/億). A barezhwould be pickinga script on the caller's behalf, invisibly.
Different core numbering system — this is the one that would actually corrupt numbers:
Brazil is short-scale, Portugal is long-scale. Note that
bilhãoandbiliãoare the sameword to the eye and differ by a factor of 1000 — a bare
ptdefaulting either way wouldn'tproduce awkward output, it would produce confidently wrong numbers. That's why
ptisexcluded even though it has only two variants, while 16-variant
enis fine.Why
enpasses anywayBecause an alias is a fixed pointer to one variant, not a dispatcher.
en→en-USmakesno claim about its siblings, several of which genuinely diverge:
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.jsTwo mechanical properties (the "very different" judgment stays human, as it must):
src/must have analias. This is what stops the backfill from being a one-time cleanup that rots.
===) 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 tobe the same object.
I verified independently of the gate: all 46 aliases have a valid in-family
aliasOf, all 46are 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 -- deis refused — bare codes are reserved, so a typo can't scaffolda full implementation where the gate expects a re-export (add-language.js:682).
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 moduleloader to follow a re-export, so
dist/en.umd.jsmust be self-contained — and it isn't evenbyte-equal to
en-US.umd.js, since it exposes the documentedn2words.englobal. src/consumers never pay for this; it costs npm tarball size only.
Explicitly not affected
Verified by differential sweep against
main, not by assertion:toCardinalandtoOrdinal: 0 differences. Every language, over0, 1, 7, 42, 100, 1000, 1234567, -5, 3.14, 999999999n. This PR touches currency only.the matrix is a pure move, and against current
mainthe default-options sweep finds 0differences in every language, fractional amounts included (the 40 fractional cases an
earlier revision listed here are on
mainnow, via fix: reject fractional amounts for currencies with no minor unit, and unbreak the docs generator #430).forms the only changed default value is pt-BR's
currency: '' → 'BRL'; every other changeis a new
currencykey added to an existing defaults object (e.g. en-US{and: true}→{and: true, currency: 'USD'}).package.jsonexportschange was required. See the bare-tag section above for the full verification.
it surfaced) is non-breaking and output-neutral — every existing import specifier, every
default currency, and every
toCardinal/toOrdinal/toCurrency(defaults) output isunchanged, verified directly against
main: the onlytoCurrencydiffs found are thesame 1,136 (
currencyoption) 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
currencyoption — previously unreachable, since no other language could namethose currencies at all:
This one is reachable on
maintoday — pt-BR accepted a free-formcurrencyand has realJapanese vocabulary, so the call above genuinely returns that string on
5.1.2. It is listedhere 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:: spellinga 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 owncurrency code explicitly instead of throwing.
Every currency-exporting language now exports
currencyDefaults.currencyandcurrencyValues.currency, so the supported set is machine-readable per language.LANGUAGES.mdgained a generated currency-coverage table.How was this tested?
npm test→ 762 tests passed vs. 453 onmain(+309 — see the Verificationsubsection 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)mainin a scratch worktree: every language × every exportedform × a value battery, plus 8
currency-option probes per currency language. All 1,176behavioral differences were triaged against
mainas it stood atabd02f2; every one fallsinto §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
mainagain on its own (same 1,176, nothing new), andseparately re-ran a step-by-step version within the branch after each structural change; see
its own verification section for both.
git-cliff --bump --unreleasedrun against the amended historyto confirm the breaking section comes out complete (see below).
New gates specifically:
currency-vocab-contract.test.js— every code in a language'scurrencyValues.currencymust 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/minorGenderfields arepresent 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 bereference-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'tresolve to one.
variant-profile-contract.test.js— a locale profile'stoCurrencywith no options mustmatch 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.mdwas re-derived by script (counts, family split, alias targets,reference identity, file shape, form parity) and every rationale claim re-run (
zh/sr/amscript pairs,
pt-BRvspt-PTscale systems,en-INlakh/crore,es-*divergence). Oneerror found and fixed in
8dbba06: the doc creditedes-MXandes-USwith diverging fromes-ESin cardinal ceiling, butes-MXcarries the same 10³⁰ ceiling — onlyes-USstopslower, at 10²¹. Everything else in both currency and alias docs verified accurate.
Release-notes footer
Resolved. The
BREAKING CHANGE:footer on thefeat(core)!commit declares the threecases above. Two problems were fixed there:
zero-exponent: ja-JP (JPY), …— and the conventional-commit parser reads a line starting withword:as thestart of a new footer. Everything from that line on was dropped. The generated
BREAKING CHANGESsection literally ended mid-sentence at "whose own currency is", withno warning from git-cliff or commitlint. Confirmed with a probe commit: blank lines and
-bullets survive the parser; a line-initialtoken:does not.§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:andis on
main, so it has been dropped from the footer rather than declared twice; what remains ofit there is the cross-language reach noted under Non-breaking improvements.
Verified on the amended history:
git-cliff --bumped-version→v6.0.0(the!infeat(core)!:carries it; see below), andgit-cliff --bump --unreleasedrenders the remainingcases in full.
One thing this leaves open: if
v5.1.3is cut frommainbefore this lands, #430's guardreaches users in that release. If
v6.0.0is the next release instead, it carries both, and theguard 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
!infeat(core)!:does that on its own. The footer onlysupplies the description text. So the truncation was never going to ship a wrong version
number; it was going to ship an accurate
6.0.0with a changelog that stopped mid-sentence.Related Issue
📚 Adding a language or a breaking change? See CONTRIBUTING.md.