Skip to main content

core.text.numeric.decimal

Decimal is a foundational stdlib type for use cases where binary float (Float = f64) is unsuitable: monetary amounts, financial calculations, scientific instrument readings, and PostgreSQL NUMERIC parameter binding. Values are represented exactly as

value = coefficient × 10^(-scale)

The implementation is pure stdlib — no new AST nodes, no new compiler intrinsics. Every operation is checked against overflow and surfaces a precise DecimalError variant when the representation cannot hold the result.

When to use Decimal

WorkloadUseReason
Money (USD, EUR, JPY, …)DecimalFloat rounding produces real money loss at scale.
PG NUMERIC parameter bindingDecimalBinary encode now bridges through encode_numeric_from_decimal.
Scientific readings with known scaleDecimalExact preservation of the instrument's resolution.
Statistics, fast loops, MLFloatDecimal is per-op slower; reach for it only when correctness needs it.
Sub-nanosecond timestampsInt nsTime should not round.

Type surface

public type Decimal is {
coefficient: Int, // signed i64; sign of value tracks sign of coefficient
scale: Int, // 0 ≤ scale ≤ 18
};

public type DecimalError is
ParseEmpty
| ParseInvalidChar { byte_offset: Int, byte: Int }
| ParseInvalidShape { reason: Text }
| ScaleOutOfRange { scale: Int }
| Overflow { op: Text }
| DivByZero;

public type RoundingMode is
HalfEven // banker's rounding (IEEE 754 default; eliminates +0.5 bias)
| HalfUp // halves away from zero
| HalfDown // halves towards zero
| Truncate; // unconditional truncate towards zero

MAX_SCALE = 18 — the largest scale that fits inside an i64 coefficient with at least one significant digit. Construction beyond this surfaces ScaleOutOfRange { scale }.

Constructors

implement Decimal {
public fn zero() -> Decimal;
public fn one() -> Decimal;
public fn from_int(n: Int) -> Decimal;
public fn from_parts(c: Int, s: Int) -> Result<Decimal, DecimalError>;
}

zero and one are canonical: (coefficient: 0, scale: 0) and (coefficient: 1, scale: 0). from_int cannot fail. from_parts rejects scale ∉ [0, 18].

Predicates

public fn is_zero(&self) -> Bool;
public fn is_negative(&self) -> Bool;
public fn is_positive(&self) -> Bool;
public fn abs(&self) -> Decimal;
public fn neg(&self) -> Decimal;

Arithmetic

public fn add(&self, other: &Decimal) -> Result<Decimal, DecimalError>;
public fn sub(&self, other: &Decimal) -> Result<Decimal, DecimalError>;
public fn mul(&self, other: &Decimal) -> Result<Decimal, DecimalError>;
public fn div(
&self,
other: &Decimal,
precision: Int,
mode: RoundingMode,
) -> Result<Decimal, DecimalError>;
public fn div_round(&self, other: &Decimal, precision: Int) -> Result<Decimal, DecimalError>;
public fn div_trunc(&self, other: &Decimal) -> Result<Decimal, DecimalError>;

add / sub align scales via the 19-entry POW10 table, then add or subtract coefficients with checked_add / checked_sub. mul uses i128 intermediate for the coefficient product before re-narrowing.

div runs textbook long division on the i64 coefficients, iterating precision + 1 long-division steps (the +1 is the rounding digit), tracks a sticky bit through the post-loop remainder, and applies the rounding policy on the (round_digit, sticky) tuple. Division is sign-aware: (-7) / 2 HalfEven yields -3.5.

let a = Decimal.from_parts(50, 1)?; // 5.0
let b = Decimal.from_parts(20, 1)?; // 2.0
let q = a.div(&b, 2, RoundingMode.HalfEven)?;
// q = Decimal { coefficient: 250, scale: 2 } → "2.50"

Rounding mode semantics

For the canonical 5/2 = 2.5 half-tie:

ModeResultNotes
HalfEven2last representable digit even (2 even, 3 odd → 2)
HalfUp3rounds halves away from zero
HalfDown2rounds halves towards zero
Truncate2discards round digit

HalfEven is the default for div_round because it eliminates the systematic +0.5 bias that plain HalfUp introduces over many operations.

Comparison

public fn compare(&self, other: &Decimal) -> Ordering;
public fn eq(&self, other: &Decimal) -> Bool;
public fn lt(&self, other: &Decimal) -> Bool;
public fn gt(&self, other: &Decimal) -> Bool;
public fn le(&self, other: &Decimal) -> Bool;
public fn ge(&self, other: &Decimal) -> Bool;

compare is sign-fast-path: differing signs decide ordering without touching coefficients. Same-sign values align scales and compare coefficients directly. In the degenerate case where scale-alignment itself would overflow, compare falls back to comparing the rendered text — slow but always correct.

Parse and render

public fn parse_decimal(text: &Text) -> Result<Decimal, DecimalError>;
public fn to_text(&self) -> Text;

parse_decimal accepts the canonical decimal grammar:

decimal = [sign] digit+ ['.' digit+]
sign = '+' | '-'

No scientific notation in V0 — exponential parsing is a V1 follow-up. Trailing zeros in the fractional part are preserved: parse_decimal("1.50") produces (coefficient: 150, scale: 2), to_text emits "1.50".

to_text is byte-exact relative to the canonical text format. Round-trip property: parse_decimal(d.to_text()) = Ok(d) for every well-formed Decimal d.

Integration with PostgreSQL NUMERIC

The PG wire codec at core.db.postgres.codec exposes two encoders that both bridge through Decimal:

// Integer fast-path (no Decimal allocation, V0).
public fn encode_numeric_from_int(
buf: &mut WireBuf,
arena: &Arena,
n: Int,
) -> Bool;

// Decimal-backed encoder (V1; non-integer values).
public fn encode_numeric_from_decimal(
buf: &mut WireBuf,
arena: &Arena,
d: &Decimal,
) -> Bool;

// Convenience wrapper: Text → Decimal → encode.
public fn encode_numeric_from_text(
buf: &mut WireBuf,
arena: &Arena,
s: &Text,
) -> Bool;

Before V1, non-integer NUMERIC parameter binding fell back to FmtText mode. After V1 every NUMERIC binding takes the binary path with full PG-side fidelity.

V0 boundaries and V1 follow-ups

SurfaceV0V1 plan
Coefficient precisionInt (i64, ~18 sig digits)BigInt coefficient
Scale range[0, 18]Tracked together with BigInt
Scientific-notation parseout of scope1.5e3 → Decimal
Square root, transcendentalsout of scopeNeeds extended-precision intermediate
Divisionshipped (HalfEven default)
Banker's roundingshipped (RoundingMode.HalfEven)

Testing

The exhaustive functional test fixture lives at vcs/specs/L2-standard/text/numeric_decimal.vr. It exercises:

  • Every constructor + every error path.
  • Add / sub / mul scale alignment + overflow detection.
  • All four rounding modes on the canonical 5/2 half-tie.
  • Sign propagation including i64::MIN saturation.
  • Round-trip parse / render fidelity including trailing-zero preservation.

Cross-references