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
-
OverflowThe result has more than
max_digitsdigits or a scale abovemax_scale. -
DivisionByZeroThe divisor is zero, or a zero base has a negative exponent.
-
ScaleOutOfRange(scale: Int)A requested scale is outside
0tomax_scale. -
WouldRoundAn exact conversion would have to drop nonzero digits.
-
IntegerOutOfRangeAn
Intoutside ±max_safe_integer, the integers exact on every target. -
PowerTooLargeThe exact power is beyond
max_power_digits; seepower. -
NoPartsallocategot no weights, orsplitfewer than one part. -
TooManyParts(count: Int)allocateorsplitwas asked for more thanmax_partsparts. -
NegativeWeight(index: Int)The
allocateweight at this index (from 0) is negative. -
ZeroTotalWeightEvery
allocateweight 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
-
EmptyTextThe 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
-or1.. -
TooManyDigits(count: Int)More than
max_digitssignificant digits; leading zeros do not count. -
TooManyFractionDigits(count: Int)More than
max_scaledigits 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/notationreads 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
-
HalfEvenTo 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.
-
HalfAwayFromZeroTo the nearest value; a tie goes away from zero. The common “half up” (HALF_UP in Java and Python).
-
HalfTowardZeroTo the nearest value; a tie goes toward zero (HALF_DOWN).
-
TowardZeroToward zero: truncation (DOWN).
-
AwayFromZeroAway from zero (UP).
-
FloorToward negative infinity.
-
CeilingToward positive infinity.
Values
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 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 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 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 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.