Skip to content
KimplifyPublic

About

Type-safe currency formatting for Kotlin Multiplatform with Compose support.

Resources

Contributing

Stars

9 stars

Watchers

0 watching

Forks

Repository files navigation

Kurrency 💱

CI Kotlin Kotlin Multiplatform Android iOS JVM JS WasmJs License

Type-safe currency formatting for Kotlin Multiplatform, with locale-aware output and optional Compose integration.

Table of Contents

Features

  • 🌍 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 sealed KurrencyError hierarchy
  • 🔄 Multiple styles — symbol, ISO code, and compact formatting
  • 📦 Lightweight — minimal dependencies

Installation

Core library

dependencies {
    implementation("org.kimplify:kurrency-core:0.5.0")
}

Compose integration (optional)

dependencies {
    implementation("org.kimplify:kurrency-core:0.5.0")
    implementation("org.kimplify:kurrency-compose:0.5.0")
}

Quick Start

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") // 0

Locale vs. fraction digits

A 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"

Working with the Currency type

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 €"

Locale Management

Predefined locales

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

Custom and system locales

// BCP 47 language tag
val austrian = KurrencyLocale.fromLanguageTag("de-AT").getOrNull()

// Device locale
val system = KurrencyLocale.systemLocale()

Compose Integration

Add kurrency-compose for Jetpack Compose Multiplatform support.

Currency input with state-based text fields

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.

rememberCurrencyFormatter

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")
        }
    }
}

LocalCurrencyFormatter

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")}")
}

Compose locale interop

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)
}

API Reference

CurrencyFormatter

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

Kurrency

// 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()): String

CurrencyStyle is one of Standard (symbol), Iso (ISO code), or Accounting (parentheses for negatives).

CurrencyFormat (interface)

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

KurrencyLocale.fromLanguageTag(languageTag: String): Result<KurrencyLocale>
KurrencyLocale.systemLocale(): KurrencyLocale

val languageTag: String        // e.g. "en-US"
val decimalSeparator: Char
val groupingSeparator: Char

Compose extensions (kurrency-compose)

class 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(): KurrencyLocale

Error Handling

Result-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 Support

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

License

Apache License 2.0 — Copyright © 2025

About

Type-safe currency formatting for Kotlin Multiplatform with Compose support.

Resources

Contributing

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages