glecimal

Exact decimal numbers for money and audit.

A Decimal is an exact value with a scale, the number of digits after the point: 19.90 has scale 2 and stays 19.90. Addition, subtraction and multiplication are exact. Division, rounding and powers drop digits, so each of them takes the scale to keep and the Rounding that decides the last digit. Nothing rounds silently and nothing uses floats.

Every value is bounded: at most max_digits significant digits and a scale of at most max_scale. A result beyond the bound is an Overflow error, never a truncated number. parse reads exactly the values a Decimal can hold, so to_string and parse are inverses, and the work any operation does is bounded by these limits.

import glecimal.{HalfEven}

pub fn line_total() -> Result(String, glecimal.ArithmeticError) {
  let assert Ok(price) = glecimal.parse("19.90")
  let assert Ok(quantity) = glecimal.parse("3")
  let assert Ok(rate) = glecimal.parse("0.0725")
  use net <- result.try(glecimal.multiply(price, quantity))
  use tax <- result.try(glecimal.multiply(net, rate))
  use tax <- result.try(glecimal.round(tax, scale: 2, rounding: HalfEven))
  use total <- result.try(glecimal.add(net, tax))
  Ok(glecimal.to_string(total))
}

== compares the canonical form, so it holds when the value and the scale are both equal: 1.5 and 1.50 are different under == and equal under compare. Use compare for numeric equality.

Types

Why an operation has no result. One type serves every operation, so a chain of use … <- result.try(…) needs no mapping between steps.

pub type ArithmeticError {
  Overflow
  DivisionByZero
  ScaleOutOfRange(scale: Int)
  WouldRound
  IntegerOutOfRange
  PowerTooLarge
  NoParts
  TooManyParts(count: Int)
  NegativeWeight(index: Int)
  ZeroTotalWeight
}

Constructors

  • Overflow

    The result has more than max_digits digits or a scale above max_scale.

  • DivisionByZero

    The divisor is zero, or a zero base has a negative exponent.

  • ScaleOutOfRange(scale: Int)

    A requested scale is outside 0 to max_scale.

  • WouldRound

    An exact conversion would have to drop nonzero digits.

  • IntegerOutOfRange

    An Int outside ±max_safe_integer, the integers exact on every target.

  • PowerTooLarge

    The exact power is beyond max_power_digits; see power.

  • NoParts

    allocate got no weights, or split fewer than one part.

  • TooManyParts(count: Int)

    allocate or split was asked for more than max_parts parts.

  • NegativeWeight(index: Int)

    The allocate weight at this index (from 0) is negative.

  • ZeroTotalWeight

    Every allocate weight is zero.

An exact decimal number: a sign, a magnitude and a scale. The representation is canonical, so two values are == exactly when their to_string texts are equal.

pub opaque type Decimal

Why parse refused a text. Positions count characters from 0.

pub type ParseError {
  EmptyText
  TextTooLong(bytes: Int)
  UnexpectedCharacter(position: Int, character: String)
  MissingDigits(position: Int)
  TooManyDigits(count: Int)
  TooManyFractionDigits(count: Int)
  InvalidGrouping(position: Int)
}

Constructors

  • EmptyText

    The text is empty.

  • TextTooLong(bytes: Int)

    The text is longer than any accepted decimal, checked before scanning.

  • UnexpectedCharacter(position: Int, character: String)

    A character that the grammar does not allow at this position.

  • MissingDigits(position: Int)

    The text ends where a digit is required, as in - or 1..

  • TooManyDigits(count: Int)

    More than max_digits significant digits; leading zeros do not count.

  • TooManyFractionDigits(count: Int)

    More than max_scale digits after the point.

  • InvalidGrouping(position: Int)

    The group separator at this position starts or ends a group that does not have three digits; only glecimal/notation reads groups.

How a result that cannot be held exactly at the requested scale chooses its last digit. Every operation that drops digits takes one; none has a default.

pub type Rounding {
  HalfEven
  HalfAwayFromZero
  HalfTowardZero
  TowardZero
  AwayFromZero
  Floor
  Ceiling
}

Constructors

  • HalfEven

    To the nearest value; a tie goes to the even digit. Banker’s rounding, the rounding of ABNT NBR 5891 and of IEEE 754 by default.

  • HalfAwayFromZero

    To the nearest value; a tie goes away from zero. The common “half up” (HALF_UP in Java and Python).

  • HalfTowardZero

    To the nearest value; a tie goes toward zero (HALF_DOWN).

  • TowardZero

    Toward zero: truncation (DOWN).

  • AwayFromZero

    Away from zero (UP).

  • Floor

    Toward negative infinity.

  • Ceiling

    Toward positive infinity.

Values

pub fn absolute_value(decimal: Decimal) -> Decimal

|decimal| at the same scale.

pub fn add(
  a: Decimal,
  b: Decimal,
) -> Result(Decimal, ArithmeticError)

a + b, exactly, at the larger of the two scales.

pub fn allocate(
  amount: Decimal,
  weights weights: List(Decimal),
) -> Result(List(Decimal), ArithmeticError)

Splits amount in proportion to weights, at the amount’s scale, so 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. Every part is within one unit (10^-scale) of its exact share. At most max_parts weights are accepted.

glecimal.allocate(hundred, weights: [one, one, one])
// -> Ok([33.34, 33.33, 33.33])
pub fn compare(a: Decimal, b: Decimal) -> order.Order

Compares numeric values, ignoring scale: 1.5 and 1.50 are Eq.

pub fn decoder() -> decode.Decoder(Decimal)

Reads a JSON string (or any dynamic string) with parse. Numbers are refused: a JSON number has already passed through a float. A refused text names its reason in the decode error’s expected.

pub fn describe_arithmetic_error(
  error: ArithmeticError,
) -> String

A sentence for logs and messages.

pub fn describe_parse_error(error: ParseError) -> String

A sentence for logs and messages.

pub fn divide(
  dividend: Decimal,
  by divisor: Decimal,
  scale scale: Int,
  rounding rounding: Rounding,
) -> Result(Decimal, ArithmeticError)

dividend ÷ divisor, rounded once to scale with rounding.

glecimal.divide(one, by: three, scale: 6, rounding: HalfEven)
// -> Ok(0.333333)
pub fn divide_with_remainder(
  dividend: Decimal,
  by divisor: Decimal,
  scale scale: Int,
) -> Result(#(Decimal, Decimal), ArithmeticError)

dividend ÷ divisor truncated toward zero at scale, with the exact remainder, so dividend is exactly quotient × divisor + remainder. The remainder has the dividend’s sign and is smaller than one 10^-scale unit of the divisor; its scale is the larger of the dividend’s scale and scale plus the divisor’s scale, down to max_scale when its trailing digits are zeros. A remainder that needs more is Overflow.

// 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))
pub fn from_int(n: Int) -> Result(Decimal, ArithmeticError)

An integer at scale 0. Integers beyond ±max_safe_integer are refused, because JavaScript has already rounded them; build those with parse(int.to_string(n)) on Erlang.

pub fn from_minor_units(
  units: Int,
  scale scale: Int,
) -> Result(Decimal, ArithmeticError)

An amount counted in units of 10^-scale, such as cents: from_minor_units(1990, scale: 2) is 19.90.

pub fn is_negative(decimal: Decimal) -> Bool
pub fn is_positive(decimal: Decimal) -> Bool
pub fn is_zero(decimal: Decimal) -> Bool
pub fn max(a: Decimal, b: Decimal) -> Decimal

The larger value; on a tie, a.

pub const max_digits: Int

The most significant digits a value holds.

pub const max_parts: Int

The most parts allocate and split produce.

pub const max_power_digits: Int

The bound on power: the base’s digits plus its scale, times the exponent’s absolute value.

pub const max_safe_integer: Int

The largest Int that from_int and to_minor_units accept: 2^53 − 1, the largest integer exact on both Erlang and JavaScript.

pub const max_scale: Int

The largest scale a value holds.

pub fn min(a: Decimal, b: Decimal) -> Decimal

The smaller value; on a tie, a.

pub fn multiply(
  a: Decimal,
  b: Decimal,
) -> Result(Decimal, ArithmeticError)

a × b, exactly, at the sum of the two scales. A scale above max_scale is Overflow: round an operand first.

pub fn multiply_rounded(
  a: Decimal,
  by b: Decimal,
  scale scale: Int,
  rounding rounding: Rounding,
) -> Result(Decimal, ArithmeticError)

a × b rounded once to scale. Unlike multiply followed by round, the exact product may exceed max_scale before it is rounded, so a price times a rate computed to 64 places still has one rounding.

pub fn negate(decimal: Decimal) -> Decimal

-decimal at the same scale.

pub fn normalize(decimal: Decimal) -> Decimal

The same value at the smallest scale that holds it exactly: 2.500 becomes 2.5, and 0.00 becomes 0.

pub fn parse(text: String) -> Result(Decimal, ParseError)

Reads the plain decimal grammar -?[0-9]+(\.[0-9]+)? and nothing else: no exponent, grouping, plus sign, spaces or locale forms (glecimal/notation reads those). The written scale is kept, so "1.50" has scale 2, and "-0.00" is 0.00. It accepts every value a Decimal holds, so it reads back anything to_string wrote.

glecimal.parse("19.90") |> result.map(glecimal.to_string)
// -> Ok("19.90")
glecimal.parse("1e3")
// -> Error(UnexpectedCharacter(position: 1, character: "e"))
pub fn power(
  base: Decimal,
  exponent exponent: Int,
  scale scale: Int,
  rounding rounding: Rounding,
) -> Result(Decimal, ArithmeticError)

base^exponent, computed exactly and rounded once to scale, so a compound factor carries exactly one named rounding. A negative exponent discounts: power(1.01, exponent: -12, …) is 1 ÷ 1.01^12. 0^0 is 1.

The exact power is bounded: when the base’s digits plus its scale, times |exponent|, exceed max_power_digits, the result is PowerTooLarge. Monthly compounding over 80 years (960 periods) at a rate written like 1.0075 fits, as does a 30-year mortgage (360 periods) at a rate with eight decimals.

pub fn rescale(
  decimal: Decimal,
  scale scale: Int,
) -> Result(Decimal, ArithmeticError)

The value at scale, exactly: zeros are appended or dropped, and a nonzero digit that would be dropped is WouldRound.

pub fn round(
  decimal: Decimal,
  scale scale: Int,
  rounding rounding: Rounding,
) -> Result(Decimal, ArithmeticError)

The value at scale. Dropping digits applies rounding; adding digits appends zeros.

pub fn scale(decimal: Decimal) -> Int

The number of digits after the point.

pub fn split(
  amount: Decimal,
  into parts: Int,
) -> Result(List(Decimal), ArithmeticError)

Splits amount into parts equal shares, as allocate does with equal weights.

pub fn subtract(
  a: Decimal,
  b: Decimal,
) -> Result(Decimal, ArithmeticError)

a - b, exactly, at the larger of the two scales.

pub fn sum(
  values: List(Decimal),
) -> Result(Decimal, ArithmeticError)

The exact total, at the largest scale among the values; sum([]) is 0.

pub fn to_minor_units(
  decimal: Decimal,
  scale scale: Int,
) -> Result(Int, ArithmeticError)

The value counted in units of 10^-scale, exactly: to_minor_units(19.90, scale: 2) is 1990. A value with nonzero digits beyond scale is WouldRound (round it first), and a count beyond ±max_safe_integer is IntegerOutOfRange.

pub fn to_string(decimal: Decimal) -> String

The canonical text at the value’s scale: digits, a point only when the scale is positive, and a leading - only for a negative value. It never uses exponent notation, and parse reads every such text back to an == value.

pub fn zero() -> Decimal

Zero at scale 0.

✨ Search Document