glecimal
A pure-Gleam exact-decimal library for money and audit. Parse, compute, round, allocate, convert and format amounts, with the same 128-digit results on Erlang and JavaScript. The guide is at glecimal.com.
- Exact, or explicit about it. Addition, subtraction and multiplication are exact. Division, rounding and powers take the scale to keep and a named rounding; no operation has a default, and nothing rounds silently.
- No floats, anywhere. There is no
from_floatand noto_float. Decimals travel as text: through JSON, PostgreSQL and forms. - The same on Erlang and JavaScript. 128 significant digits on both targets, in pure Gleam: no FFI, no NIFs, no BigInt.
- Bounded. At most 128 digits and a scale of 64. A result past the bound is an error, never a truncated number. Every operation has a stated limit, so hostile input costs bounded work.
- Text that round-trips.
parsereads exactly the values a decimal can hold, so whateverto_stringwrites reads back unchanged. - Money that adds up. Allocation never loses a cent.
Moneyholds the ISO 4217 currencies at their minor units and never mixes them. - Checked against an independent oracle. 11,658 vectors computed by
Python’s
decimalmodule pass on both targets, with property tests beside them.
Installation
gleam add glecimal
glecimal works with Gleam 1.11 or later and gleam_stdlib 0.60 or later, on
the Erlang and JavaScript targets. It depends on nothing else.
Quick start
import gleam/result
import glecimal.{type Decimal, HalfEven}
/// Price × quantity, plus tax rounded to cents.
pub fn line_total(
price: Decimal,
quantity: Decimal,
tax_rate: Decimal,
) -> Result(Decimal, glecimal.ArithmeticError) {
use net <- result.try(glecimal.multiply(price, quantity))
use tax <- result.try(glecimal.multiply(net, tax_rate))
use tax <- result.try(glecimal.round(tax, scale: 2, rounding: HalfEven))
glecimal.add(net, tax)
}
// line_total(19.90, 3, 0.0725) -> Ok(64.03)
Values come in through parse, which reads the plain form and says exactly
what it refused:
glecimal.parse("19.90") // -> Ok(19.90)
glecimal.parse("19,90") // -> Error(UnexpectedCharacter(position: 2, character: ","))
glecimal.parse("1e3") // -> Error(UnexpectedCharacter(position: 1, character: "e"))
Guide
Scale and equality
A decimal keeps the scale it was written or computed with: "19.90" has scale
2 and prints as 19.90. Addition and subtraction keep the larger scale, and
multiplication adds the scales.
The representation is canonical, so == holds exactly when the value and the
scale are both equal: 1.5 == 1.50 is False. For numeric equality and order,
use compare, which works with list.sort:
glecimal.compare(one_and_a_half, one_and_a_half_at_scale_2) // -> Eq
list.sort(amounts, glecimal.compare)
rescale changes the scale exactly, and refuses to drop a nonzero digit.
normalize drops trailing zeros.
What to_string writes, parse reads: a rate computed to 40 places can be
stored as text and read back unchanged.
Rounding
Every operation that drops digits takes one of seven roundings, named so they cannot be misread:
| Rounding | 2.5 | -2.5 | 2.4 | -2.4 | Also known as |
|---|---|---|---|---|---|
HalfEven | 2 | -2 | 2 | -2 | banker’s rounding; ABNT NBR 5891 |
HalfAwayFromZero | 3 | -3 | 2 | -2 | half up, commercial rounding |
HalfTowardZero | 2 | -2 | 2 | -2 | half down |
TowardZero | 2 | -2 | 2 | -2 | truncation |
AwayFromZero | 3 | -3 | 3 | -3 | up |
Floor | 2 | -3 | 2 | -3 | toward negative infinity |
Ceiling | 3 | -2 | 3 | -2 | toward positive infinity |
glecimal.round(amount, scale: 2, rounding: HalfEven)
Division
divide computes the exact quotient and rounds it once:
glecimal.divide(ten, by: three, scale: 2, rounding: HalfEven) // -> Ok(3.33)
When you need what is left over rather than a rounded quotient,
divide_with_remainder truncates the quotient and returns the exact
remainder, so quotient × divisor + remainder is the dividend:
// How many shares at 33.33 does 1000.00 buy, and what is left?
glecimal.divide_with_remainder(cash, by: price, scale: 0)
// -> Ok(#(30, 0.10))
Products that round once
multiply is exact, so its scale is the sum of the operands’ scales and can
pass 64. multiply_rounded rounds the exact product once, whatever the
operands’ scales:
// price × a rate computed to 64 places, rounded once to cents
glecimal.multiply_rounded(price, by: rate, scale: 2, rounding: HalfEven)
Powers and interest
power computes the exact power and rounds it once, so a compound factor
carries exactly one named rounding. A negative exponent discounts:
// BRL 1000.00 at 0.75% a month for 12 months
use factor <- result.try(
glecimal.power(monthly, exponent: 12, scale: 10, rounding: HalfEven),
)
// factor -> 1.0938068977, and 1000.00 × factor to cents -> 1093.81
use discount <- result.try(
glecimal.power(monthly, exponent: -12, scale: 10, rounding: HalfEven),
)
// discount -> 0.9142381550, so 1000.00 in a year is worth 914.24 today
The exact power is bounded: the base’s digits plus its scale, times the
exponent, must not exceed 10,000. Monthly compounding over 80 years at a rate
written like 1.0075 fits, as does a 30-year mortgage at a rate with eight
decimals.
Allocation
allocate splits an amount in proportion to weights, and the parts always add
up to the amount exactly. Each part is its exact share rounded toward zero; the
units left over go one each to the parts with the largest dropped fractions,
the earlier part first on a tie.
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])
Allocation errors share ArithmeticError, so a split ends a result.try
chain without mapping. At most 100,000 parts (glecimal.max_parts).
Money and currencies
glecimal/currency holds the 165 ISO 4217 currencies that have minor units,
generated from the list the maintenance agency published on 2026-09-17, as
constants: currency.brl, currency.usd, currency.jpy. currency.from_code
reads a code, as Result(Currency, Nil). The table only grows: a code
withdrawn from ISO 4217 keeps a deprecated constant and from_code still
reads it, so records written years ago read back. glecimal/money
holds an amount at exactly its currency’s scale:
import glecimal/currency
import glecimal/money
pub fn invoice() -> Result(List(money.Money), money.MoneyError) {
use price <- result.try(money.from_minor_units(19_990, in: currency.brl))
use rate <- result.try(
glecimal.parse("0.0725") |> result.map_error(money.InvalidAmount),
)
use tax <- result.try(money.multiply(price, by: rate, rounding: HalfEven))
use total <- result.try(money.add(price, tax))
money.split(total, into: 3)
}
// -> Ok([BRL 71.47, BRL 71.46, BRL 71.46])
Adding BRL to USD is CurrencyMismatch. money.new refuses an amount
with nonzero digits beyond its currency’s minor units (WouldRound);
money.new_rounded takes a rounding, and money.multiply rounds the exact
product once. money.zero(in: currency.brl) is BRL 0.00, and never fails.
money.to_string writes the audit form BRL 214.39, and money.parse reads
it back.
money.convert changes currency at a rate, the units of the target currency
per unit of the source, and rounds the exact product once to the target’s
minor units:
money.convert(price, to: currency.usd, at: rate, rounding: HalfEven)
// BRL 199.90 at 0.1834 -> Ok(USD 36.66)
// the same price in JPY at 27.3851 -> Ok(JPY 5474), in BHD at 0.06903 -> Ok(BHD 13.799)
Display and forms
glecimal/notation writes and reads decimals as people do in each locale.
A notation is named by its locale’s BCP 47 tag, with the separators CLDR (the
Unicode locale data that browsers use) gives it: constants for common
locales, and for_locale for a tag from a browser or an Accept-Language
header.
import glecimal/notation
notation.format(amount, notation.pt_br) // -> "1.234.567,89"
notation.format(amount, notation.en_us) // -> "1,234,567.89"
notation.format(amount, notation.de_ch) // -> "1'234'567.89"
notation.for_locale("fr-FR") // -> Ok(notation.fr_fr)
notation.for_locale("es-ES") // -> Error(Nil)
The constants are pt_br, en_us, en_gb, de_de, fr_fr and de_ch;
for_locale knows 81 locales (notation.locales(), from CLDR 48). A locale
is there only when a notation reproduces CLDR’s formatting exactly, so some
are not: es-ES, it-IT and pt-PT leave four-digit numbers ungrouped,
en-IN groups in twos, and sv-SE writes a Unicode minus. for_locale
returns Error(Nil) for those rather than guess; fall back to plain or to a
notation from notation.new.
Parsing is strict: digit groups have exactly three digits or there are none,
and a grouped number never starts with a zero group. So 0.500 typed into a
Brazilian field is refused, not read as five hundred. Where a locale groups
with a space or an apostrophe, any space or either apostrophe that people type
counts.
notation.parse("1.234,56", notation.pt_br) // -> Ok(1234.56)
notation.parse("1234,56", notation.pt_br) // -> Ok(1234.56)
notation.parse("12.34,5", notation.pt_br) // -> Error(InvalidGrouping(position: 2))
notation.parse("0.500", notation.pt_br) // -> Error(InvalidGrouping(position: 1))
notation.parse("1 234,5", notation.fr_fr) // -> Ok(1234.5)
A notation carries separators only: add the currency symbol yourself.
money.format(price, notation.pt_br) writes a Money amount at its
currency’s scale (1.234,50); a plain decimal formats at its own scale, so
rescale it to two places first. notation.brazilian and notation.english
from 1.0 still work, deprecated in favour of pt_br and en_us.
JSON
Decimals travel as JSON strings. glecimal.decoder() reads them and refuses
numbers, which have already passed through a float. money.decoder() reads
{"amount": "214.39", "currency": "BRL"}.
json.parse(body, decode.at(["total"], glecimal.decoder()))
json.object([#("total", json.string(glecimal.to_string(total)))])
PostgreSQL and Squirrel
Read numeric columns as text and pass parameters as text, so no value passes
through a float on the way:
-- src/billing/sql/invoice_total.sql
select id, total::text as total from billing.invoice where id = $1;
-- src/billing/sql/record_total.sql
update billing.invoice set total = $2::text::numeric where id = $1;
Squirrel then generates total: String, which the row mapping reads with
glecimal.parse, and takes a String parameter, which glecimal.to_string
fills. PostgreSQL prints a numeric(14, 2) column at scale 2, so the scale
survives the round trip. Writing, PostgreSQL silently rounds extra decimals
to the column’s scale, half away from zero, so rescale (or round, naming
the rounding) to the column’s scale before writing.
Lustre
The same code runs in the browser: read a form field with notation.parse,
compute with the same functions, and show the result with notation.format.
The browser and the server agree to the last digit.
Limits
| Bound | Value |
|---|---|
| Significant digits | 128 (glecimal.max_digits) |
| Scale | 0 to 64 (glecimal.max_scale) |
parse | every value within the two bounds above; text up to 130 bytes |
allocate, split | 100,000 parts (glecimal.max_parts) |
from_int, to_minor_units | ±(2^53 − 1), the integers exact on every target |
power: (digits + scale of the base) × exponent | 10,000 |
A result past these bounds is an error, never a truncated number: Overflow,
TooManyDigits, PowerTooLarge or TooManyParts.
Additions take well under a microsecond on both targets, and the slowest operation, a power at its bound, takes tens of milliseconds.
What the checks hold
- 11,658 vectors from Python’s
decimalmodule (scripts/vectors.py), an oracle independent of this code, cover parsing, arithmetic, comparison, rounded products, division and rounding in every mode, division with remainder, rescaling, powers and allocation. They pass on Erlang and on JavaScript. - Property tests (qcheck): text round-trips, computed values read back,
rounded products match multiply-then-round, division with remainder is
exact, conversion rounds once, division undoes multiplication, rounding is idempotent and bracketed by
FloorandCeiling, allocation adds up and stays within one unit of every exact share, money splits add up, notation round-trips, andparsenever crashes on arbitrary text. - A source ratchet (
scripts/check-gleam.sh) holdssrc/at zero crash sites (panic,let assert), floats, FFI and discarded errors. - The currency table is regenerated from the pinned ISO 4217 list and
compared with the source (
scripts/currencies.py --check).
scripts/check.sh runs all of it, with the formatter and a warnings-as-errors
build on both targets.
Licence
Apache-2.0.
About
glecimal is built and maintained by Enverium, a sustainability reporting platform, for its audit-grade applications.