glecimal.

Exact decimals for Gleam, for money and audit.

glecimal adds, multiplies, divides and rounds decimal numbers without binary floating point, and gives the same answer on Erlang and in the browser.

The same sum, kept as decimal digits and as a binary float.
gleam add glecimal

For Gleam 1.11 or later, on Erlang and JavaScript. It depends on the standard library alone.

Why not floats

A float stores a binary fraction, and most decimal amounts have none: 0.1 is stored as the nearest binary value, a little off. The errors are tiny, but they add up into cents that do not reconcile and reports that disagree with the bank.

glecimal holds a number as its decimal digits and a scale, the count of digits after the point. It keeps three promises:

1A decimal keeps its scale

The scale is part of the number you wrote: 19.90 stays 19.90, two places, and prints that way. Adding keeps the larger scale; multiplying adds the scales.

glecimal.parse("19.90")           // -> Ok(19.90)
glecimal.parse("19,90")
// -> Error(UnexpectedCharacter(position: 2, character: ","))
glecimal.add(price, tip)          // 19.90 + 0.1 -> Ok(20.00)
glecimal.multiply(price, three)   // 19.90 × 3 -> Ok(59.70)

Because the scale is part of the value, == treats 19.9 and 19.90 as different. Compare amounts with compare, which looks only at the number and sorts lists with list.sort(amounts, glecimal.compare).

2Every rounding has a name

Nothing rounds by default. Each operation that drops digits takes the scale to keep and one of seven roundings, named so that nobody has to remember what “up” meant.

Rounding to a whole number
Rounding2.5−2.52.4Also called
HalfEven2−22banker’s rounding; ABNT NBR 5891
HalfAwayFromZero3−32half up, commercial rounding
HalfTowardZero2−22half down
TowardZero2−22truncation
AwayFromZero3−33up
Floor2−32toward negative infinity
Ceiling3−23toward positive infinity

3One rounding per answer

When an answer cannot be exact, glecimal computes it exactly first and rounds it once, at the end. A tax on a price, a quotient, a compound interest factor: each carries a single rounding that an auditor can name.

glecimal.divide(ten, by: three, scale: 2, rounding: HalfEven)
// -> Ok(3.33)

glecimal.multiply_rounded(price, by: tax_rate, scale: 2, rounding: HalfEven)
// 19.99 × 0.0725 -> Ok(1.45)

glecimal.power(monthly, exponent: 12, scale: 10, rounding: HalfEven)
// 1.0075 to the 12th -> Ok(1.0938068977); a negative exponent discounts

4Sharing out money

Splitting a bill is where cents go missing. split and allocate give each part its exact share rounded down, then hand the leftover cents to the parts that lost the most, so the parts always add back to the whole.

glecimal.split(hundred, into: 3)
// -> Ok([33.34, 33.33, 33.33])

glecimal.allocate(thousand, weights: [fifty, thirty, twenty])
// -> Ok([500.00, 300.00, 200.00])

To know how many units an amount buys and what is left, divide_with_remainder returns both, and the two always rebuild the amount exactly.

glecimal.divide_with_remainder(cash, by: share_price, scale: 0)
// 1000.00 ÷ 33.33 -> Ok(#(30, 0.10)): thirty shares, ten cents left

5Money and currencies

A Money is an amount in one of the 165 ISO 4217 currencies with minor units, always at that currency’s number of decimals: BRL 10.50, JPY 1050, BHD 1.050. Adding reais to dollars is an error, not a number.

money.add(reais, dollars)
// -> Error(CurrencyMismatch(expected: BRL, found: USD))

money.convert(price, to: currency.usd, at: rate, rounding: HalfEven)
// BRL 199.90 at 0.1834 -> Ok(USD 36.66)

money.zero(in: currency.jpy)   // JPY 0

When ISO withdraws a currency, its code keeps reading back, so records written years ago still open.

6Numbers people type and read

People write 1.234,56 in Brazil, 1,234.56 in the United States and 1 234,56 in France. glecimal/notation names each notation by its locale, with the separators that CLDR, the Unicode locale data browsers use, gives it.

notation.format(amount, notation.pt_br)      // "1.234.567,89"
notation.format(amount, notation.de_ch)      // "1'234'567.89"
notation.for_locale("fr-FR")                 // -> Ok(notation.fr_fr), from a browser's locale

Reading a form field is strict: digit groups of three or none, and never a leading zero group, so 0.500 typed into a Brazilian field is refused instead of read as five hundred. Where a locale groups with spaces, any space people type counts.

notation.parse("1.234,56", notation.pt_br)   // -> Ok(1234.56)
notation.parse("0.500", notation.pt_br)      // -> Error(InvalidGrouping(position: 1))
notation.parse("1 234,5", notation.fr_fr)    // -> Ok(1234.5)

7At the edges

Decimals travel as text, so no value passes through a float on its way in or out.

JSON
glecimal.decoder() reads strings and refuses numbers, which have already been through a float. Write values with json.string(glecimal.to_string(total)).
PostgreSQL
Read numeric columns as total::text and write through $1::text::numeric. Round to the column’s scale before writing, or PostgreSQL rounds for you, silently.
The browser
The same functions run in Lustre, so a total computed in a form matches the server’s to the last digit.

8Limits, and errors that say why

Every limit is stated, and passing one is an error you can match on. There is no panic, no not-a-number and no infinity.

The bounds
Significant digits in a value128
Digits after the point64
Parts in one split100,000
Digits in an exact power10,000
Integers taken from Int±9,007,199,254,740,991

Errors carry their reason and, for text, the position: UnexpectedCharacter(position: 1, character: "e") for 1e3, WouldRound for a conversion that would drop a digit, Overflow for a result past 128 digits.

How it is checked

Every operation is compared with Python’s decimal module, an implementation written by other people for other reasons: more than eleven thousand cases, run on Erlang and on JavaScript. Property tests check the laws that matter for money, such as parts adding up to the whole and text reading back unchanged. The source holds no panics, no floats and no foreign code, and a check in the build keeps it that way.