finanza/decimal
Fixed-point decimal arithmetic with explicit rounding.
A Decimal is represented internally as a signed
integer coefficient and an exponent:
value = coefficient × 10^exponent
Decimal is pub opaque; construct values through from_int,
from_string, or new, and inspect them through
coefficient and exponent.
Precision boundary
On the JavaScript target, Gleam’s Int is a 64-bit IEEE 754 number,
so coefficients are limited to ±(2^53 − 1) = 9_007_199_254_740_991.
Operations that would produce a larger coefficient return
PrecisionExceeded. On the Erlang target,
integers are arbitrary precision; the same bound is enforced anyway
so behaviour is consistent across targets.
Types
Errors returned by arithmetic operations.
pub type ArithmeticError {
DivisionByZero
PrecisionExceeded
}
Constructors
Errors returned by validated constructors
(try_new, try_from_int).
pub type ConstructError {
CoefficientTooLarge
}
Constructors
-
CoefficientTooLargeThe supplied coefficient — or the value implied once the exponent is applied (
|coefficient| × 10^exponentforexponent ≥ 0) — would exceed±9_007_199_254_740_991(the JavaScript-safe integer ceiling). Such a value cannot round-trip throughto_stringandfrom_string, so construction fails fast rather than producing aDecimalthe library cannot read back.
Fixed-point decimal value. Construct via from_int,
from_string, or new.
pub opaque type Decimal
Errors returned by format_checked when the
thousands or decimal separator arguments would produce an
ambiguous or un-parseable rendering.
pub type FormatError {
MultiCharSeparator(field: String, value: String)
SeparatorsCollide(value: String)
EmptyDecimalSeparator
}
Constructors
-
MultiCharSeparator(field: String, value: String)A separator argument has more than one grapheme. The
fieldis either"thousands"or"decimal";valueis the offending input echoed back so the caller can route the failure to its locale layer. -
SeparatorsCollide(value: String)The
thousandsanddecimal_separatorarguments are equal (and non-empty). The rendered output would contain the same character at every separator position and parsers could not disambiguate the integer / fractional split. -
EmptyDecimalSeparatorThe
decimal_separatorargument is the empty string. The rendered output would lose the integer / fractional split entirely ("1234.5"would format as"1234,5").
Errors returned by from_string.
pub type ParseError {
EmptyInput
InvalidCharacter(char: String, position: Int)
MultipleDecimalPoints
MultipleSigns
NoDigits
ParsedValueTooLarge
}
Constructors
-
EmptyInputThe input was the empty string or contained only whitespace.
-
InvalidCharacter(char: String, position: Int)The input contained a character that is not a digit, sign, or decimal point.
-
MultipleDecimalPointsThe input contained more than one decimal point.
-
MultipleSignsThe input contained more than one sign character.
-
NoDigitsThe input contained no digits (e.g.
"+",".","-."). -
ParsedValueTooLargeThe parsed coefficient would exceed
±9_007_199_254_740_991(the JavaScript-safe integer ceiling). Such a value cannot be represented faithfully on the JavaScript target; rather than silently corrupt it (and emit unparseable strings fromto_string), parsing fails fast.
Values
pub fn add(
a a: Decimal,
b b: Decimal,
) -> Result(Decimal, ArithmeticError)
Add two decimals.
When one operand is zero the result is just the other operand —
we short-circuit without going through align. Without the
short-circuit, align would try to scale the smaller-exponent
operand up to the larger’s exponent (e.g. 1 × 10^20 for
new(1, 20) + zero()), which can exceed max_safe_coefficient
and surface a spurious PrecisionExceeded despite the
mathematical result fitting trivially. When both operands are
zero we still return zero, but with the smaller of the two
exponents so that add(a, b) == add(b, a) holds at the level of
structural equality (the same invariant align provided before).
pub fn compare(a a: Decimal, b b: Decimal) -> order.Order
Total ordering. Two values with the same numeric value compare as equal even when their exponents differ.
pub fn divide(
a a: Decimal,
b b: Decimal,
digits digits: Int,
mode mode: rounding.Mode,
) -> Result(Decimal, ArithmeticError)
Divide a by b, returning a result rounded to digits decimal
places using mode.
Returns DivisionByZero when b is zero, or
PrecisionExceeded when the intermediate
representation would exceed ±9_007_199_254_740_991 (the
JavaScript-safe integer ceiling). See
max_safe_digits for the practical upper
bound on digits: requests above that value will exceed the
precision window even when both operands are unit-magnitude,
and larger operands shrink the headroom further. For the
per-call boundary the rule of thumb is
`digits ≤ floor(log10(max_safe_coefficient / abs(a.coefficient)))
- (b.exponent − a.exponent)
. Callers needing more precision should reach for a dedicated arbitrary-precision library (Pythondecimalwithprec=50+`, etc.).
pub fn equal(a a: Decimal, b b: Decimal) -> Bool
Equality test by numeric value, not by representation.
equal(new(coefficient: 100, exponent: -2), one()) is True.
pub fn format(
d d: Decimal,
thousands thousands: String,
decimal_separator decimal_separator: String,
) -> String
Render a Decimal with custom thousands and decimal separators.
format(d, thousands: ",", decimal_separator: ".") // "1,234.56"
format(d, thousands: ".", decimal_separator: ",") // "1.234,56" (German)
format(d, thousands: "", decimal_separator: ".") // "1234.56"
Equivalent to to_string when thousands is empty
and decimal_separator is ".".
pub fn format_checked(
d d: Decimal,
thousands thousands: String,
decimal_separator decimal_separator: String,
) -> Result(String, FormatError)
Like format, but validates the separator arguments
against the locale-formatter contract and returns the failure as
a Result instead of producing a garbled rendering. The checks
catch the three configuration mistakes that format would
otherwise swallow:
thousandsordecimal_separatorwith more than one grapheme (the function is a single-character separator formatter; longer inputs almost always indicate a config-pipeline bug)- identical
thousandsanddecimal_separator(the output would render the same character at every separator position and be un-parseable round-trip) - empty
decimal_separator(the output would lose the integer / fractional split entirely)
thousands empty is still accepted and yields “no grouping” —
the standard contract for to_string. Use this entry point when
the separator arguments come from configuration, locale data, or
any other dynamic source where surfacing the failure as a value
matters; keep using format when the arguments are call-site
literals you control.
pub fn from_float(
value value: Float,
) -> Result(Decimal, ParseError)
Build a Decimal from a Float.
The conversion goes through float.to_string |> from_string,
which means the resulting Decimal matches Gleam’s textual
rendering of the float. That rendering is the same one
gleam_stdlib uses for string.inspect(value) and is generally
the “shortest IEEE-754 round-trip” form on both targets — so
inputs like 0.5 and 3.14 survive intact, while pathological
floats (0.1 +. 0.2 → 0.30000000000000004) carry their full
double expansion into the resulting Decimal.
Returns the same ParseError variants from_string does so a
runtime float that for any reason cannot be parsed (NaN /
Infinity surface as non-numeric strings on Erlang) propagates as
Error(_) rather than panicking.
Use this when the input genuinely is a Float (exchange-rate
APIs, telemetry); prefer from_string or try_new when the
caller already holds a textual or coefficient-and-exponent
representation, since those skip the float round-trip and stay
target-portable.
pub fn from_int(n n: Int) -> Decimal
Build a Decimal from an integer.
Panics if |n| > 9_007_199_254_740_991 (such a coefficient
cannot round-trip through to_string and
from_string). Use try_from_int
when the input is supplied by a caller and might exceed the
safe range — that variant returns a Result instead of
panicking.
pub fn from_string(
input input: String,
) -> Result(Decimal, ParseError)
Parse a decimal from a string. Accepts an optional leading + or
-, decimal digits, and at most one . separator. Scientific
notation (1e10, 1.5E+2, 1e-10) is also accepted: the mantissa
is parsed by the same rules and the trailing [eE][+-]?digits
shifts the resulting Decimal’s exponent. This matches
dataprep/parse.float’s convention so values produced by Float
→ String round-trips on either target flow into decimal
without an intermediate string.replace step.
from_string("3.14") // Ok(Decimal with coefficient=314, exponent=-2)
from_string("-0.5") // Ok(Decimal with coefficient=-5, exponent=-1)
from_string("1e10") // Ok(Decimal with coefficient=1, exponent=10)
from_string("1.5e+2") // Ok(Decimal with coefficient=15, exponent=1)
from_string("1e-10") // Ok(Decimal with coefficient=1, exponent=-10)
from_string("") // Error(EmptyInput)
from_string("1.2.3") // Error(MultipleDecimalPoints)
Whitespace. Leading and trailing Unicode whitespace is trimmed
before parsing — not only ASCII space / tab / newline but also
NO-BREAK SPACE (U+00A0), NARROW NO-BREAK SPACE (U+202F), IDEOGRAPHIC
SPACE (U+3000), the en/em-space family (U+2000–U+200A), and the line /
paragraph separators (U+2028 / U+2029). This matches the Unicode
White_Space property, so values copied from web pages, locale
currency formatting, or CSV / Excel exports parse without the caller
pre-cleaning them. Whitespace between digits is still rejected (e.g.
"1 . 5" and "1\u{00a0}5" both fail) — trimming is at the ends only.
pub const max_safe_digits: Int
Upper bound on digits arguments to divide (and the
other operations that scale by 10^digits) when both operands are
unit-magnitude. max_safe_coefficient is ≈ 9 × 10^15, so
10^16 > max_safe_coefficient and division to 16 fractional
digits already exceeds the safe range even for 1/1. Larger
operands shrink the practical headroom further; treat
max_safe_digits as the best case ceiling, not a guarantee.
For any specific division, the safe digits value is bounded by
floor(log10(max_safe_coefficient / abs(a.coefficient))) plus
b.exponent − a.exponent. Callers that need more precision should
reach for a dedicated arbitrary-precision library (e.g. Python
decimal with prec=50+).
pub fn multiply(
a a: Decimal,
b b: Decimal,
) -> Result(Decimal, ArithmeticError)
Multiply two decimals.
pub fn negate(d d: Decimal) -> Decimal
Negate the value. Always safe (the coefficient sign flips but magnitude does not change).
pub fn new(
coefficient coefficient: Int,
exponent exponent: Int,
) -> Decimal
Build a Decimal directly from a coefficient and exponent.
new(coefficient: 1234, exponent: -2) represents 12.34.
Panics on either of the two overflow paths:
|coefficient| > 9_007_199_254_740_991— the coefficient itself exceeds the JS-safe integer ceiling.exponent > 0and|coefficient| × 10^exponent > 9_007_199_254_740_991— the coefficient is safe but the implied rendered value (the integer thatto_stringwould emit) is not.
The panic message distinguishes the two paths so a debugging caller can tell whether to shrink the coefficient or shrink the exponent.
Use try_new when the inputs are supplied by a
caller and might exceed the safe range — that variant returns
a Result instead of panicking.
pub fn rescale(
d d: Decimal,
target_exponent target_exponent: Int,
mode mode: rounding.Mode,
) -> Result(Decimal, ArithmeticError)
Force the decimal to a specific exponent. When the new exponent is
finer (smaller), the coefficient grows by zero-padding (may overflow).
When the new exponent is coarser (larger), digits are dropped using
mode.
pub fn round(
d d: Decimal,
digits digits: Int,
mode mode: rounding.Mode,
) -> Decimal
Round to digits decimal places. The result always carries exactly
digits fractional places: a coarser-precision input is zero-padded
(so to_string(round(from_int(2000), 2, _)) is "2000.00"), a
finer-precision input is rounded using mode, and digits == 0
yields a plain integer rendering. This is the only useful contract
for monetary formatting — to_string(round(d, 2, _)) is "12.30",
never "12.3".
round is the non-failing form of rescale targeting
exponent -digits; the two share one implementation so their results
cannot drift. Padding can in principle overflow
±9_007_199_254_740_991 (only reachable on the JavaScript target);
when it would, round falls back to returning the input unchanged
rather than failing. Reach for rescale directly when you need to
observe that overflow as an Error.
pub fn subtract(
a a: Decimal,
b b: Decimal,
) -> Result(Decimal, ArithmeticError)
Subtract b from a.
pub fn to_int(d d: Decimal) -> Result(Int, ArithmeticError)
Convert to a plain integer.
Succeeds only when d is exactly integer-valued — no fractional
part and the resulting integer fits within
±max_safe_coefficient. Both a non-zero fractional remainder and a
coefficient overflow produce PrecisionExceeded. Use
to_int_truncated when the fractional part
should be dropped toward zero, or
to_int_rounded when it should be rounded using
a specific rounding.Mode.
to_int(from_int(7)) // Ok(7)
let assert Ok(d) = from_string("12.34")
to_int(d) // Error(PrecisionExceeded)
let assert Ok(d) = from_string("12.00")
to_int(d) // Ok(12)
pub fn to_int_rounded(
d d: Decimal,
mode mode: rounding.Mode,
) -> Result(Int, ArithmeticError)
Round d to an integer using mode and return that integer.
mode is applied as if rounding to zero decimal places — so
to_int_rounded(d, mode: rounding.HalfEven) is the natural fit
for the “I rounded to N decimals, now give me the integer”
workflow. Returns PrecisionExceeded only when the rounded
integer cannot fit within ±max_safe_coefficient.
pub fn to_int_truncated(
d d: Decimal,
) -> Result(Int, ArithmeticError)
Truncate d toward zero (rounding.Down) and return the integer.
Drops the fractional part regardless of its size, so -1.9 becomes
-1 and 1.9 becomes 1. Use
to_int_rounded when a different rounding mode
is needed. Returns PrecisionExceeded only when the truncated
integer cannot fit within ±max_safe_coefficient (a fractional
part on its own never causes a failure here, unlike
to_int).
pub fn to_string(d d: Decimal) -> String
Render a Decimal as a plain string. Preserves the encoded
exponent (new(coefficient: 100, exponent: -2) renders as "1.00",
not "1").
pub fn truncate(d d: Decimal, digits digits: Int) -> Decimal
Truncate to digits decimal places (rounding toward zero). Like
round, the result carries exactly digits fractional
places — a coarser-precision input is zero-padded — but the rounding
direction is always toward zero (rounding.Down).
pub fn try_from_int(n n: Int) -> Result(Decimal, ConstructError)
Build a Decimal from an integer, returning a Result.
Returns Error(CoefficientTooLarge) when
|n| > 9_007_199_254_740_991, which is the threshold above
which the resulting Decimal cannot round-trip through
to_string and from_string.
pub fn try_new(
coefficient coefficient: Int,
exponent exponent: Int,
) -> Result(Decimal, ConstructError)
Build a Decimal from a coefficient and exponent, returning a
Result.
Returns Error(CoefficientTooLarge) when the rendered value
would overflow the safe range — either because
|coefficient| > 9_007_199_254_740_991, or because a positive
exponent would push the rendered integer
(|coefficient| × 10^exponent) past that bound. Such a value
cannot round-trip through to_string and
from_string.