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.
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:
- Exact, or rounded once by a rule you name. Addition, subtraction and multiplication are exact. Anything that must drop digits asks how.
- The same answer everywhere. Up to 128 digits on Erlang and on JavaScript, in pure Gleam, with no floats inside.
- Bounded. Past a stated limit you get an error to handle, never a quietly wrong number.
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 | 2.5 | −2.5 | 2.4 | Also called |
|---|---|---|---|---|
HalfEven | 2 | −2 | 2 | banker’s rounding; ABNT NBR 5891 |
HalfAwayFromZero | 3 | −3 | 2 | half up, commercial rounding |
HalfTowardZero | 2 | −2 | 2 | half down |
TowardZero | 2 | −2 | 2 | truncation |
AwayFromZero | 3 | −3 | 3 | up |
Floor | 2 | −3 | 2 | toward negative infinity |
Ceiling | 3 | −2 | 3 | toward 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
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 withjson.string(glecimal.to_string(total)).- PostgreSQL
- Read
numericcolumns astotal::textand 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.
| Significant digits in a value | 128 |
|---|---|
| Digits after the point | 64 |
| Parts in one split | 100,000 |
| Digits in an exact power | 10,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.