fix(core): locale-aware symbols, surfaced platform failures, and indicator separators - #19
Merged
Merged
Conversation
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
force-pushed
the
fix/currency-indicator-separator
branch
from
August 20, 2026 00:28
c47c246 to
6572815
Compare
Merkost
force-pushed
the
fix/currency-indicator-separator
branch
from
August 20, 2026 01:30
6572815 to
6bb842f
Compare
…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
force-pushed
the
fix/currency-indicator-separator
branch
from
August 20, 2026 01:50
6bb842f to
2185f5e
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)formatWithOptionsassembled every leading indicator the same way, so an ISO code or a currency name abutted the digits exactly as a symbol should:SymbolDisplay.ISO_CODEAUD1,234.56AUD 1,234.56SymbolDisplay.NAMEAustralian Dollars1,234.56Australian Dollars 1,234.56SymbolDisplay.SYMBOLA$1,234.56A$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.fromCodealso stored the code as given, sofromCode("aud")produced aKurrencywhosecodewas"aud"— it now normalises to upper case, matching the validation that already accepted either case.2. The options path lost currency disambiguation (
f8fcb46— closes #18)formatWithOptionsandformatMinorUnitsWithOptionsread their symbol fromCurrencyMetadata, 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.
CurrencyFormatgainsgetCurrencySymbolOrDefault, answered by each platform from its own locale data and falling back to the metadata symbol where a platform has nothing better:Currency.getSymbol(locale)Currency.getName(…, SYMBOL_NAME, …)NSNumberFormatter.currencySymbolIntl.NumberFormat(…).formatToPartsTo a US reader AUD is now
A$100while USD stays$100, and the options path agrees with the locale-aware path on the symbol.CurrencyIndicatorSeparatorTestnow pins its locale: the symbol it prints is locale-dependent (Node's en-US default resolves AUD toA$), and only the separator is under test.3. Platform failures were swallowed, and symbol position was re-derived per call (
6572815— closes #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,formatCurrencyStyleResultcould never report a failure: a caller checking theResultwas 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:
CurrencyFormatmethod stays lenient on every implementation and hands back the original amount — no caller of the documented API changes behaviour;CurrencyFormatterImplgains an internalformatOrThrow, andCurrencyFormatteruses it for the paths that return aResult, mapping the throwable toKurrencyError.FormattingFailure;formatLenientlyhelper;getFractionDigitsOrDefault,getCurrencySymbolOrDefault) keep theirs;Putting the throw behind an internal method rather than on the
CurrencyFormatmethods themselves matters: those methods are public and documented as lenient, andCurrencyFormatterImplis constructible by consumers. Making them throw would have meant one interface type with two different failure behaviours depending on the concrete class.detectSymbolPositionthen round-tripped the platform formatter on every format call to learn one boolean, and both its fallbacks returnedLEADINGsilently. Combined with the swallow, a platform that could not format returned"1", no symbol was found, andLOCALE_DEFAULTquietly becameLEADINGfor every locale — a German locale rendering$1.234,56with nothing reported. It now goes throughformatOrThrow, 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
@Volatilecopy-on-write maps, preserving the thread-safety this class documents.Tests
20 new tests across six classes:
CurrencyIndicatorSeparatorTest,KurrencyCodeNormalisationTest,LocaleAwareSymbolTest,SymbolPositionDetectionTest,LenientFacadeContractTest(common) andPlatformFailureSurfacesTest(JVM — the swallow's removal is only observable at the implementation layer).Suites run green on every platform the library targets:
jvmTestjsNodeTestwasmJsBrowserTestiosSimulatorArm64TestAndroid has no host-test task in this project (its tests are device tests), so
androidMainis verified bycompileAndroidMainandcompileAndroidDeviceTestonly — worth a device run before release.Compatibility
CurrencyFormat.getCurrencySymbolOrDefaultships with a default implementation returning the supplied fallback, so existing implementors keep compiling.formatOrThrowis 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:
formatCurrencyStyleResultand its siblings can now returnResult.failure(KurrencyError.FormattingFailure)where they previously always reported success. Callers who check theResultfinally get the truth; callers who use the non-ResultAPI are untouched.