glecimal

Package Version Hex Docs

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.

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:

Rounding2.5-2.52.4-2.4Also known as
HalfEven2-22-2banker’s rounding; ABNT NBR 5891
HalfAwayFromZero3-32-2half up, commercial rounding
HalfTowardZero2-22-2half down
TowardZero2-22-2truncation
AwayFromZero3-33-3up
Floor2-32-3toward negative infinity
Ceiling3-23-2toward 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

BoundValue
Significant digits128 (glecimal.max_digits)
Scale0 to 64 (glecimal.max_scale)
parseevery value within the two bounds above; text up to 130 bytes
allocate, split100,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) × exponent10,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

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.

✨ Search Document