Skip to content

fix(core): locale-aware symbols, surfaced platform failures, and indicator separators - #19

Merged
Merkost merged 5 commits into
mainfrom
fix/currency-indicator-separator
Aug 20, 2026
Merged

fix(core): locale-aware symbols, surfaced platform failures, and indicator separators#19
Merkost merged 5 commits into
mainfrom
fix/currency-indicator-separator

Conversation

@Merkost

@Merkost Merkost commented Aug 20, 2026

Copy link
Copy Markdown
Member

Fixes the three open issues, plus the leading-indicator separator bug that started this branch. One commit per fix.

1. A leading ISO code or currency name ran into the amount (a7604fa)

formatWithOptions assembled every leading indicator the same way, so an ISO code or a currency name abutted the digits exactly as a symbol should:

Options Before After
SymbolDisplay.ISO_CODE AUD1,234.56 AUD 1,234.56
SymbolDisplay.NAME Australian Dollars1,234.56 Australian Dollars 1,234.56
SymbolDisplay.SYMBOL A$1,234.56 A$1,234.56 (unchanged)

A symbol is a glyph and abuts the amount; a code or a name is a word and needs a space. Trailing indicators already got one. Kurrency.fromCode also stored the code as given, so fromCode("aud") produced a Kurrency whose code was "aud" — it now normalises to upper case, matching the validation that already accepted either case.

2. The options path lost currency disambiguation (f8fcb46closes #18)

formatWithOptions and formatMinorUnitsWithOptions read their symbol from CurrencyMetadata, which holds one generic symbol per currency, so every dollar currency rendered as a bare $. To a US reader, AUD and USD were indistinguishable, and migrating from the locale-aware formatters to the options API silently dropped the disambiguation.

The symbol depends on the reader's locale, not only on the currency, so no static table can express it. CurrencyFormat gains getCurrencySymbolOrDefault, answered by each platform from its own locale data and falling back to the metadata symbol where a platform has nothing better:

Platform Source
JVM Currency.getSymbol(locale)
Android ICU Currency.getName(…, SYMBOL_NAME, …)
iOS NSNumberFormatter.currencySymbol
JS / Wasm Intl.NumberFormat(…).formatToParts

To a US reader AUD is now A$100 while USD stays $100, and the options path agrees with the locale-aware path on the symbol.

CurrencyIndicatorSeparatorTest now pins its locale: the symbol it prints is locale-dependent (Node's en-US default resolves AUD to A$), and only the separator is under test.

3. Platform failures were swallowed, and symbol position was re-derived per call (6572815closes #20, #21)

Every platform implementation ended its formatting path with getOrElse { amount }, returning the unformatted input on failure — a plausible-looking number indistinguishable from a real result. Because the exception was already caught and discarded, formatCurrencyStyleResult could never report a failure: a caller checking the Result was told everything succeeded.

The lenient contract is worth keeping for a UI-facing formatter, so it stays exactly where it is documented. What was missing is a path that does not swallow:

  • every CurrencyFormat method stays lenient on every implementation and hands back the original amount — no caller of the documented API changes behaviour;
  • CurrencyFormatterImpl gains an internal formatOrThrow, and CurrencyFormatter uses it for the paths that return a Result, mapping the throwable to KurrencyError.FormattingFailure;
  • the seven duplicated fallbacks collapse into one formatLeniently helper;
  • accessors that promise a default in their name (getFractionDigitsOrDefault, getCurrencySymbolOrDefault) keep theirs;
  • Android's compact path still degrades to standard formatting — that yields a correct value rather than an input — and now propagates if standard formatting fails too.

Putting the throw behind an internal method rather than on the CurrencyFormat methods themselves matters: those methods are public and documented as lenient, and CurrencyFormatterImpl is constructible by consumers. Making them throw would have meant one interface type with two different failure behaviours depending on the concrete class.

detectSymbolPosition then round-tripped the platform formatter on every format call to learn one boolean, and both its fallbacks returned LEADING silently. Combined with the swallow, a platform that could not format returned "1", no symbol was found, and LOCALE_DEFAULT quietly became LEADING for every locale — a German locale rendering $1.234,56 with nothing reported. It now goes through formatOrThrow, so a swallowed failure cannot masquerade as a placement, and each fallback logs why it was taken.

Both the placement and the locale-aware symbol from fix 2 are fixed for a formatter's lifetime, so both are resolved once per currency and cached instead of building a platform formatter on every call. The caches are @Volatile copy-on-write maps, preserving the thread-safety this class documents.

Tests

20 new tests across six classes: CurrencyIndicatorSeparatorTest, KurrencyCodeNormalisationTest, LocaleAwareSymbolTest, SymbolPositionDetectionTest, LenientFacadeContractTest (common) and PlatformFailureSurfacesTest (JVM — the swallow's removal is only observable at the implementation layer).

Suites run green on every platform the library targets:

Suite Tests Failures
jvmTest 486 0
jsNodeTest 480 0
wasmJsBrowserTest 477 0
iosSimulatorArm64Test 478 0

Android has no host-test task in this project (its tests are device tests), so androidMain is verified by compileAndroidMain and compileAndroidDeviceTest only — worth a device run before release.

Compatibility

CurrencyFormat.getCurrencySymbolOrDefault ships with a default implementation returning the supplied fallback, so existing implementors keep compiling. formatOrThrow is internal, so it adds nothing to the public surface.

No behaviour change for any caller of the lenient API — that is the whole point of routing the throw through a separate internal path. The one visible difference is intended: formatCurrencyStyleResult and its siblings can now return Result.failure(KurrencyError.FormattingFailure) where they previously always reported success. Callers who check the Result finally get the truth; callers who use the non-Result API are untouched.

The indicator was concatenated straight onto the amount whenever it led, so
CurrencyFormatOptions.ISO rendered "AUD1,234.56" and SymbolDisplay.NAME rendered
"Australian Dollars1,234.56". A trailing indicator already got a space, so the
separator existed — it was chosen by position rather than by what the indicator
is.

A symbol abuts the amount; a code or a name is a word and has to be separated
from it. The separator is now decided by symbolDisplay, which leaves SYMBOL
untouched and fixes ISO_CODE and NAME in both preset and explicit forms.

Also normalises the code in Kurrency.fromCode: ISO 4217 codes are upper case,
and fromCode("aud").code previously returned "aud", so a caller comparing it
against Kurrency.AUD or a string constant silently failed. Formatting was
unaffected because the metadata lookup uppercases internally.

Neither case had a test, which is why both shipped. Thirteen added across the
two behaviours, including the negative and parenthesised-negative assembly and
the trailing position, which must keep its single space.
formatWithOptions and formatMinorUnitsWithOptions read their symbol from
CurrencyMetadata, which holds one generic symbol per currency. Every
dollar currency therefore rendered as a bare "$": to a US reader, AUD and
USD were indistinguishable, and moving from the locale-aware formatters
to the options API silently dropped that disambiguation.

The symbol depends on the reader's locale, not only on the currency, so a
static table cannot express it. CurrencyFormat gains
getCurrencySymbolOrDefault, which each platform answers from its own
locale data — Currency.getSymbol on JVM, ICU SYMBOL_NAME on Android,
NSNumberFormatter.currencySymbol on iOS, Intl.NumberFormat.formatToParts
on JS and Wasm — and falls back to the metadata symbol where a platform
has nothing better. AUD now renders as "A$100" to a US reader while USD
stays "$100", and the options path agrees with the locale-aware path.

The separator test pins its locale for the same reason: the symbol it
prints is locale-dependent, and only the separator is under test.

Closes #18
@Merkost
Merkost force-pushed the fix/currency-indicator-separator branch from c47c246 to 6572815 Compare August 20, 2026 00:28
@Merkost Merkost changed the title fix(core): separate a leading ISO code or currency name from the amount fix(core): locale-aware symbols, surfaced platform failures, and indicator separators Aug 20, 2026
@Merkost
Merkost force-pushed the fix/currency-indicator-separator branch from 6572815 to 6bb842f Compare August 20, 2026 01:30
…y lookups

Two problems that compound each other.

Every platform implementation ended its formatting path with
`getOrElse { amount }`, returning the unformatted input on failure. That
is a plausible-looking number no caller can distinguish from a real
result, and because the exception was already caught and discarded,
`formatCurrencyStyleResult` could never report a failure — a caller doing
the right thing was told everything succeeded.

The lenient contract is worth keeping for a UI-facing formatter, so it
stays exactly where it is documented: the `CurrencyFormat` methods still
hand back the original amount, on every implementation, and nothing that
calls them changes behaviour. What was missing is a path that does not
swallow. `CurrencyFormatterImpl` gains an internal `formatOrThrow`, and
CurrencyFormatter uses it for the paths that return a `Result`, mapping
the throwable to KurrencyError.FormattingFailure. The seven duplicated
fallbacks collapse into one `formatLeniently` helper. Android's compact
path still degrades to standard formatting, which yields a correct value
rather than an input, and now propagates if that fails too.

detectSymbolPosition then round-tripped the platform formatter on every
single format call to learn one boolean, and both its fallbacks returned
LEADING silently. Combined with the swallow, a platform that could not
format returned "1", no symbol was found, and LOCALE_DEFAULT quietly
became LEADING for every locale — a German locale rendering $1.234,56
with nothing reported. It now goes through formatOrThrow, so a swallowed
failure cannot masquerade as a placement, and each fallback logs why it
was taken.

Both the placement and the locale-aware symbol are fixed for a
formatter's lifetime — its locale cannot change — so both are resolved
once per currency and cached rather than building a platform formatter on
every call. The caches are `@Volatile` copy-on-write maps, keeping the
thread-safety this class documents.

Closes #20
Closes #21
@Merkost
Merkost force-pushed the fix/currency-indicator-separator branch from 6bb842f to 2185f5e Compare August 20, 2026 01:50
Bumps appVersionName, which feeds coordinates() for kurrency-core,
kurrency-compose and kurrency-deci.

README: the install snippets, the new getCurrencySymbolOrDefault on the
CurrencyFormat surface, and the locale section, which claimed the locale
"only controls presentation". It also picks the symbol — an AUD amount is
"A$100.00" to a US reader and "$100.00" to an Australian one — which is
the whole point of the #18 fix. Both figures in that snippet are asserted
in LocaleAwareSymbolTest rather than left as prose.

CHANGELOG: dates the 0.5.0 entry and refreshes the version-support table,
which still listed 0.2.3 as current.
@Merkost
Merkost merged commit 036827e into main Aug 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant