Type-safe currency formatting for Kotlin Multiplatform, with locale-aware output and optional Compose integration.
- Features
- Installation
- Quick Start
- Locale Management
- Compose Integration
- API Reference
- Error Handling
- Platform Support
- License
- 🌍 Multiplatform — Android, iOS, JVM, JS, and WasmJs from a single API
- 🌐 Locale-aware — separators, grouping, and symbol placement per locale
- 🎨 Compose integration — ready-to-use composables with reactive locale updates
- ✅ Type-safe errors —
Result-based API with a sealedKurrencyErrorhierarchy - 🔄 Multiple styles — symbol, ISO code, and compact formatting
- 📦 Lightweight — minimal dependencies
dependencies {
implementation("org.kimplify:kurrency-core:0.5.0")
}dependencies {
implementation("org.kimplify:kurrency-core:0.5.0")
implementation("org.kimplify:kurrency-compose:0.5.0")
}Create a CurrencyFormatter for a locale (the no-argument constructor uses the system locale), then format with Result-based methods.
import org.kimplify.kurrency.CurrencyFormatter
val formatter = CurrencyFormatter() // system locale
formatter.formatCurrencyStyleResult("1234.56", "USD") // "$1,234.56" (en-US)
formatter.formatIsoCurrencyStyleResult("1234.56", "USD") // "USD 1,234.56"
formatter.formatCompactStyleResult("1234567.89", "USD") // "$1.2M"Fraction digits are a property of the currency and never vary by locale, so they are available statically:
CurrencyFormatter.getFractionDigits("USD") // Result.success(2)
CurrencyFormatter.getFractionDigitsOrDefault("JPY") // 0A currency always uses the same number of fraction digits, regardless of where it is displayed:
- USD → 2 digits, JPY → 0 digits, BHD → 3 digits
The locale controls presentation — decimal separator, grouping separator, symbol placement, and
spacing — and it also picks the symbol itself, which is what keeps currencies sharing the $ glyph
apart:
val us = CurrencyFormatter(KurrencyLocale.US)
us.formatMinorUnitsWithOptions(10_000, "AUD", CurrencyFormatOptions.STANDARD) // "A$100.00"
us.formatMinorUnitsWithOptions(10_000, "USD", CurrencyFormatOptions.STANDARD) // "$100.00"An Australian reader sees plain $100.00 for AUD, because that is the symbol in their locale.
val us = CurrencyFormatter(KurrencyLocale.US)
val de = CurrencyFormatter(KurrencyLocale.GERMANY)
us.formatCurrencyStyleResult("1234.56", "USD") // "$1,234.56"
de.formatCurrencyStyleResult("1234.56", "USD") // "1.234,56 $"
// JPY has no fraction digits in any locale
us.formatCurrencyStyleResult("1234", "JPY") // "¥1,234"Currencies are represented by Kurrency. Build one from a code with fromCode, or use a predefined constant.
import org.kimplify.kurrency.Kurrency
val usd = Kurrency.fromCode("USD").getOrThrow()
val eur = Kurrency.EUR
val valid = Kurrency.isValid("USD") // true
usd.formatAmount("1234.56").getOrNull() // "$1,234.56" (system locale)
eur.formatAmount("1234.56", locale = KurrencyLocale.GERMANY).getOrNull() // "1.234,56 €"import org.kimplify.kurrency.CurrencyFormatter
import org.kimplify.kurrency.KurrencyLocale
val formatter = CurrencyFormatter(KurrencyLocale.JAPAN)
formatter.formatCurrencyStyleResult("1234.56", "JPY") // "¥1,235"Available constants include:
KurrencyLocale.US // en-US
KurrencyLocale.UK // en-GB
KurrencyLocale.CANADA // en-CA
KurrencyLocale.CANADA_FRENCH // fr-CA
KurrencyLocale.GERMANY // de-DE
KurrencyLocale.FRANCE // fr-FR
KurrencyLocale.ITALY // it-IT
KurrencyLocale.SPAIN // es-ES
KurrencyLocale.JAPAN // ja-JP
KurrencyLocale.CHINA // zh-CN
KurrencyLocale.KOREA // ko-KR
KurrencyLocale.BRAZIL // pt-BR
KurrencyLocale.RUSSIA // ru-RU
KurrencyLocale.SAUDI_ARABIA // ar-SA
KurrencyLocale.INDIA // hi-IN// BCP 47 language tag
val austrian = KurrencyLocale.fromLanguageTag("de-AT").getOrNull()
// Device locale
val system = KurrencyLocale.systemLocale()Add kurrency-compose for Jetpack Compose Multiplatform support.
Use rememberCurrencyOutputTransformation with Foundation's state-based BasicTextField.
The state holds raw minor-unit digits: "1234" displays as $12.34 for USD in the US locale.
Compose handles cursor and selection offsets for the formatted output.
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.input.KeyboardType
import org.kimplify.kurrency.KurrencyLocale
import org.kimplify.kurrency.compose.rememberCurrencyOutputTransformation
@Composable
fun CurrencyInput() {
val state = rememberTextFieldState(initialText = "1234")
val transformation = rememberCurrencyOutputTransformation(
currencyCode = "USD",
locale = KurrencyLocale.US
)
BasicTextField(
state = state,
outputTransformation = transformation,
inputTransformation = InputTransformation {
if (asCharSequence().any { it !in '0'..'9' }) revertAllChanges()
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
lineLimits = TextFieldLineLimits.SingleLine
)
}The optional input filter rejects an entire edit containing anything outside ASCII 0–9;
the number keyboard alone does not validate input. Store digits without signs, decimal
separators, or currency symbols. Output formatting only changes the display, leaving
state.text untouched. Empty text displays formatted zero while remaining empty in the state.
The default input scale comes from the currency: "1234" means USD 12.34, JPY 1234, or
BHD 1.234. Override fractionDigits to change that scale; it does not set display precision.
The platform formatter still uses the currency's normal fraction digits. If formatting fails,
including for an invalid currency code, the display falls back to the synthesized decimal amount.
Omit locale to follow the current Compose locale. Outside composition, construct
CurrencyOutputTransformation directly; its default is the system locale. For
value/onValueChange text fields, CurrencyVisualTransformation and
rememberCurrencyVisualTransformation remain available.
The formatter is recreated when the locale changes (key-based recomposition). Formatting is cheap, so call it directly during composition rather than caching it.
import org.kimplify.kurrency.compose.rememberCurrencyFormatter
import org.kimplify.kurrency.KurrencyLocale
@Composable
fun PriceDisplay(amount: String, currencyCode: String) {
var locale by remember { mutableStateOf(KurrencyLocale.US) }
val formatter = rememberCurrencyFormatter(locale)
Column {
Text("Price: ${formatter.formatCurrencyStyle(amount, currencyCode)}")
Button(onClick = { locale = KurrencyLocale.GERMANY }) {
Text("Switch to German locale")
}
}
}Provide a formatter to an entire subtree via CompositionLocal.
import org.kimplify.kurrency.compose.ProvideCurrencyFormatter
import org.kimplify.kurrency.compose.LocalCurrencyFormatter
import org.kimplify.kurrency.KurrencyLocale
@Composable
fun App() {
ProvideCurrencyFormatter(locale = KurrencyLocale.US) {
ProductScreen()
}
}
@Composable
fun ProductScreen() {
val formatter = LocalCurrencyFormatter.current
Text("Price: ${formatter.formatCurrencyStyle("99.99", "USD")}")
}Convert a Compose Locale to a KurrencyLocale:
import androidx.compose.ui.text.intl.Locale
import org.kimplify.kurrency.KurrencyLocale
import org.kimplify.kurrency.compose.fromComposeLocale
@Composable
fun MyComposable() {
val kurrencyLocale = KurrencyLocale.fromComposeLocale(Locale.current)
val formatter = rememberCurrencyFormatter(kurrencyLocale)
}A formatter bound to a locale. Fraction-digit lookups are available on the companion object.
// Construction
CurrencyFormatter(locale: KurrencyLocale = KurrencyLocale.systemLocale())
// Instance formatting (Result-based)
fun formatCurrencyStyleResult(amount: String, currencyCode: String): Result<String>
fun formatIsoCurrencyStyleResult(amount: String, currencyCode: String): Result<String>
fun formatCompactStyleResult(amount: String, currencyCode: String): Result<String>
// Companion (fraction digits do not vary by locale)
CurrencyFormatter.getFractionDigits(currencyCode: String): Result<Int>
CurrencyFormatter.getFractionDigitsOrDefault(currencyCode: String): Int// Factory and validation (no public constructor)
Kurrency.fromCode(code: String): Result<Kurrency>
Kurrency.isValid(code: String): Boolean
// Predefined constants: USD, EUR, GBP, JPY, AUD, CAD, CHF, CNY, INR, KRW, MXN, ...
// Instance formatting
fun formatAmount(amount: String, style: CurrencyStyle = CurrencyStyle.Standard, locale: KurrencyLocale = KurrencyLocale.systemLocale()): Result<String>
fun formatAmount(amount: Double, style: CurrencyStyle = CurrencyStyle.Standard, locale: KurrencyLocale = KurrencyLocale.systemLocale()): Result<String>
fun formatAmountOrEmpty(amount: String, style: CurrencyStyle = CurrencyStyle.Standard, locale: KurrencyLocale = KurrencyLocale.systemLocale()): StringCurrencyStyle is one of Standard (symbol), Iso (ISO code), or Accounting (parentheses for negatives).
The shared surface implemented per platform. These methods return a plain String (falling back to the input on failure); use the *Result methods on CurrencyFormatter for explicit error handling.
interface CurrencyFormat {
fun getFractionDigitsOrDefault(currencyCode: String, default: Int = 2): Int
fun getCurrencySymbolOrDefault(currencyCode: String, default: String): String
fun formatCurrencyStyle(amount: String, currencyCode: String): String
fun formatIsoCurrencyStyle(amount: String, currencyCode: String): String
fun formatCompactStyle(amount: String, currencyCode: String): String
}KurrencyLocale.fromLanguageTag(languageTag: String): Result<KurrencyLocale>
KurrencyLocale.systemLocale(): KurrencyLocale
val languageTag: String // e.g. "en-US"
val decimalSeparator: Char
val groupingSeparator: Charclass CurrencyOutputTransformation(
currencyCode: String,
locale: KurrencyLocale = KurrencyLocale.systemLocale(),
fractionDigits: Int = CurrencyFormatter.getFractionDigitsOrDefault(currencyCode).coerceAtLeast(0)
) : OutputTransformation
@Composable fun rememberCurrencyOutputTransformation(
currencyCode: String,
locale: KurrencyLocale = KurrencyLocale.current(),
fractionDigits: Int = CurrencyFormatter.getFractionDigitsOrDefault(currencyCode).coerceAtLeast(0)
): CurrencyOutputTransformation
@Composable fun rememberCurrencyFormatter(locale: KurrencyLocale = KurrencyLocale.current()): CurrencyFormat
@Composable fun rememberSystemCurrencyFormatter(): CurrencyFormat
val LocalCurrencyFormatter: ProvidableCompositionLocal<CurrencyFormat>
@Composable fun ProvideCurrencyFormatter(locale: KurrencyLocale, content: @Composable () -> Unit)
@Composable fun ProvideSystemCurrencyFormatter(content: @Composable () -> Unit)
fun KurrencyLocale.Companion.fromComposeLocale(composeLocale: Locale): KurrencyLocale
@Composable fun KurrencyLocale.Companion.current(): KurrencyLocaleResult-based methods return Result<String>, with failures modeled as KurrencyError:
CurrencyFormatter(KurrencyLocale.US)
.formatCurrencyStyleResult("1234.56", "USD")
.onSuccess { println(it) }
.onFailure { error ->
when (error) {
is KurrencyError.InvalidAmount -> println("Invalid amount")
is KurrencyError.InvalidCurrencyCode -> println("Invalid currency")
else -> println("Formatting error")
}
}| Error | Meaning |
|---|---|
KurrencyError.InvalidCurrencyCode |
Unknown or malformed currency code |
KurrencyError.InvalidAmount |
Amount could not be parsed |
KurrencyError.FormattingFailure |
Platform formatting error |
KurrencyError.FractionDigitsFailure |
Could not resolve fraction digits |
KurrencyError.InvalidLocale |
Unrecognized locale tag |
| Platform | Backend |
|---|---|
| Android (API 24+) | ICU (android.icu) |
| iOS (13+) | NSNumberFormatter |
| JVM (17+) | java.text.NumberFormat |
| JS (Browser / Node.js) | Intl.NumberFormat |
| WasmJs (Browser) | Intl.NumberFormat |
Apache License 2.0 — Copyright © 2025