decimal-kmp
1.0.1indexedTiny decimal arithmetic API for precise monetary and exchange-rate values avoiding binary floating point; supports parsing, formatted I/O, 38-digit decimal context, rounding, base-unit conversions, serialization.
Tiny decimal arithmetic API for precise monetary and exchange-rate values avoiding binary floating point; supports parsing, formatted I/O, 38-digit decimal context, rounding, base-unit conversions, serialization.
Tiny Kotlin Multiplatform decimal numbers for money, balances, exchange rates, and other values that should not go through binary floating point math.
Decimal is a small common API backed by native decimal implementations:
java.math.BigDecimalFoundation.NSDecimalNumberIt supports arithmetic, explicit rounding, formatted parsing and display, base-unit conversions,
and kotlinx.serialization.
To keep platform behavior predictable, JVM and Android intentionally follow the same numeric
limits as Apple NSDecimalNumber: values may use up to 38 significant digits and a base-10 decimal
exponent from -128 through 127. Inputs outside that envelope are rejected, arithmetic uses the
same 38-significant-digit decimal context, and exponent overflow is rejected instead of working only
on BigDecimal platforms.
Decimal is published to Maven Central. Replace <version> with the latest version shown on the
badge above:
implementation("dev.voir:decimal:<version>")
For Kotlin Multiplatform projects, add it to the source set that needs decimal support:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.voir:decimal:<version>")
}
}
}
The library exposes Decimal as a kotlinx.serialization type. It depends on serialization core;
add kotlinx-serialization-json in your app if you want JSON encoding and decoding.
The current build publishes these Kotlin Multiplatform targets:
JavaScript, WebAssembly, Linux native, Windows native, and macOS x64 are not configured yet.
import dev.voir.decimal.Decimal
import dev.voir.decimal.Rounding
import dev.voir.decimal.decimal
val price = decimal("19.99")
val quantity = Decimal.fromInt(3)
val total = price * quantity
println(total.toPlainString()) // 59.97
println(total.toFormattedString(maximumFractionDigits = 2)) // 59.97
val third = decimal("1").divide(decimal(), scale = , rounding = Rounding.HALF_UP)
println(third.toPlainString())
Prefer creating decimals from strings or integers. Decimal.fromDouble is available for finite
Double values, but decimal text is usually the clearest way to preserve the value you mean.
Apple targets are backed by Foundation.NSDecimalNumber, so this library treats its limits as the
portable contract for every platform:
| Rule | Meaning |
|---|---|
| Significant digits |
For example, a 38-digit integer is valid, 1 followed by 127 zeros is valid, and 0. followed by
127 zeros and then 1 is valid. A 39-digit non-zero coefficient, 1 followed by 128 zeros, or a
non-zero value smaller than 1e-128 is outside the portable range.
The JVM and Android implementation still uses BigDecimal internally, but it validates parsed
values against these same rules and runs addition, subtraction, and multiplication with a 38-digit
half-up decimal context so tests do not pass on JVM and then behave differently on iOS or macOS.
On Apple targets, addition, subtraction, multiplication, and division round the exact result in
shared Kotlin code, because Foundation truncates some results and reports overflow with Objective-C
exceptions that terminate Kotlin/Native processes.
import dev.voir.decimal.Decimal
import dev.voir.decimal.decimal
import dev.voir.decimal.formattedDecimal
val amount = decimal("1234.5678")
val sameAmount = Decimal.parse("1234.5678")
val cents = Decimal.ofInteger()
fromInt = Decimal.fromInt()
fromLong = Decimal.fromLong()
grouped = formattedDecimal()
european = formattedDecimal(
,
decimalSeparator = ,
groupingSeparators = setOf(),
)
Plain decimal parsing accepts values like 123, -0.01, and +42. It intentionally rejects
scientific notation, incomplete decimals like .1 or 1., and grouped text. Use
parseFormatted or formattedDecimal for user-facing grouped input.
For text where rejection is expected, such as a form field, use the OrNull variants instead of
catching exceptions. They accept exactly the same input as parse and parseFormatted:
val typed = Decimal.parseOrNull(input) ?: return showError("Enter an amount like 12.50")
val localized = Decimal.parseFormattedOrNull("1.234,56", decimalSeparator = ',', groupingSeparators = setOf('.'))
import dev.voir.decimal.Rounding
import dev.voir.decimal.decimal
val balance = decimal("100.00")
val deposit = decimal("25.50")
val fee = decimal("0.75")
val newBalance = balance + deposit - fee
println(newBalance.toPlainString())
subtotal = decimal() * decimal()
println(subtotal.toPlainString())
ratio = decimal().divide(decimal(), scale = , rounding = Rounding.DOWN)
println(ratio.toPlainString())
The / operator uses a default scale of 18 and Rounding.HALF_UP:
val value = decimal("1") / decimal("3")
println(value.toPlainString()) // 0.333333333333333333
For domain logic, prefer divide(..., scale, rounding) so the precision and rounding policy are
visible at the call site.
Division rounds the exact quotient once. scale is the maximum number of fractional digits: when a
quotient would need more than 38 significant digits, it is rounded to 38 significant digits with
the same rounding mode instead of failing. Quotients are also never kept below 1e-128.
val large = decimal("1000000000000000000000") / decimal("3")
println(large.toPlainString()) // 333333333333333333333.33333333333333333
Decimal includes six rounding modes, with the same meaning as java.math.RoundingMode:
import dev.voir.decimal.Rounding
import dev.voir.decimal.decimal
decimal("1.235").setScale(2, Rounding.HALF_UP).toPlainString() // 1.24
decimal("1.239").setScale(2, Rounding.DOWN).toPlainString()
decimal().setScale(, Rounding.UP).toPlainString()
decimal().setScale(, Rounding.HALF_EVEN).toPlainString()
decimal().setScale(, Rounding.FLOOR).toPlainString()
decimal().setScale(, Rounding.CEILING).toPlainString()
New rounding modes may be added in minor releases, so give exhaustive when expressions over
Rounding an else branch.
Equality, hashing, and ordering are numeric, so trailing zeros never matter. This differs from
BigDecimal.equals, where 1.0 and 1.00 are not equal.
import dev.voir.decimal.decimal
import dev.voir.decimal.sum
decimal() == decimal()
decimal() > decimal()
prices = listOf(decimal(), decimal(), decimal())
prices.sorted()
prices.max()
prices.sum()
maxOf(decimal(), decimal())
-decimal()
decimal().signum()
decimal().isZero()
Use toPlainString() for canonical storage and APIs. Use toFormattedString() for display.
dev.voir.decimal.Rounding
dev.voir.decimal.decimal
value = decimal()
value.toPlainString()
value.toFormattedString(maximumFractionDigits = )
value.toFormattedString(maximumFractionDigits = , groupingSeparator = )
value.toFormattedString(maximumFractionDigits = , decimalSeparator = , groupingSeparator = )
value.toFormattedString(maximumFractionDigits = , rounding = Rounding.DOWN)
decimal().toFormattedString(maximumFractionDigits = )
decimal().toFormattedString(maximumFractionDigits = , minimumFractionDigits = )
movePointLeft, movePointRight, ofInteger, multiplyInteger, and divideInteger are useful
when converting between integer base units and display units.
import dev.voir.decimal.Decimal
import dev.voir.decimal.Rounding
val satoshis = Decimal.ofInteger("2100000000000000")
val bitcoins = satoshis.movePointLeft(8)
println(bitcoins.toPlainString()) // 21000000
println(bitcoins.movePointRight(8).toIntegerString()) // 2100000000000000
val wei = Decimal.ofInteger("123456789123456789123456789")
val ether = wei.divideInteger(, scale = , rounding = Rounding.DOWN)
println(ether.toPlainString())
Decimal is serializable with kotlinx.serialization. Values are encoded as plain decimal
strings, which keeps JSON stable and avoids precision loss.
import dev.voir.decimal.Decimal
import dev.voir.decimal.decimal
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class InvoiceLine(
val amount: Decimal,
)
val encoded = Json.encodeToString(InvoiceLine(decimal("1234.5678")))
println(encoded) // {"amount":"1234.5678"}
Failures are regular Kotlin exceptions with the same type and message on every platform. On Apple
targets, no Objective-C exception escapes from Decimal, so invalid input cannot terminate the
process.
| Exception | When |
|---|
Messages name the rejected value and the limit it broke. Long input is shortened, so text from untrusted sources such as JSON never floods logs:
Invalid decimal text "1e3": expected plain base-10 text such as 123, +42, or -0.01.
Decimal "111111111111111111111111111111111111111" has 39 significant digits; at most 38 are supported.
Division by zero: "-1.5" / 0.
When decoding JSON, DecimalSerializer passes these exceptions through; kotlinx.serialization's own
SerializationException is also an IllegalArgumentException, so one catch covers both.
Common factories:
Common operations:
Issues and pull requests are welcome. Good contributions for a small numeric library include:
Please keep changes focused and include tests for behavior changes. ./gradlew build runs the
common tests on JVM, Android, macOS, and the iOS simulator, plus JVM cross-checks against
BigDecimal. The build also checks the public API against the dumps in decimal/api; when a pull
request changes the API on purpose, run ./gradlew updateKotlinAbi and commit the updated dumps.
Decimal is available under the Apache License, Version 2.0. See LICENSE.
Surfaced from shared tags and platforms — no rankings paid for.
| Target | Backend |
|---|
| JVM | BigDecimal |
| Android | BigDecimal |
| iOS x64 | NSDecimalNumber |
| iOS arm64 | NSDecimalNumber |
| iOS simulator arm64 | NSDecimalNumber |
| macOS arm64 | NSDecimalNumber |
| At most 38 after insignificant trailing zeros are compacted |
| Decimal exponent | The stored base-10 exponent must be from -128 through 127 |
| Non-finite values | NaN, infinities, and incomplete decimal text are rejected |
| Scientific notation | Not accepted by parsers; pass plain decimal text instead |
| Mode | Behavior |
|---|
Rounding.HALF_UP | Round to nearest; ties round away from zero |
Rounding.HALF_EVEN | Round to nearest; ties round to the even neighbor (banker's) |
Rounding.DOWN | Drop discarded digits; move toward zero |
Rounding.UP | Round away from zero if any discarded digit is non-zero |
Rounding.FLOOR | Round toward negative infinity |
Rounding.CEILING | Round toward positive infinity |
IllegalArgumentException | Empty or malformed text, or a value outside the 38-digit/exponent envelope |
IllegalArgumentException | A result outside the exponent range, such as an overflowing product |
IllegalArgumentException | An invalid argument, such as a negative scale or equal separators |
ArithmeticException | Division by zero |
Decimal.parse(value: String)Decimal.of(value: String)decimal(value: String)Decimal.parseFormatted(...)Decimal.parseOrNull(value: String)Decimal.parseFormattedOrNull(...)formattedDecimal(...)Decimal.ofInteger(value: String)Decimal.fromInt(value: Int)Decimal.fromLong(value: Long)Decimal.fromDouble(value: Double)Decimal.zero()Decimal.one()+, -, *, /, unary -add, subtract, multiply, divide, negatemultiplyInteger, divideIntegermovePointLeft, movePointRightsetScaleabs, signum, isZerocompareTo, <, >, and standard library helpers such as minOf, maxOf, and sortedIterable<Decimal>.sum()toPlainStringtoIntegerStringtoFormattedString