Skip to content

std::decimal

Exact decimal arithmetic, for the numbers that are counted rather than measured.

docs/positioning.md opens by saying Khora is well suited to financial reconciliation, and until this module there was no way to write ten pence. Float is IEEE and the numbers design note explains at length why it implements neither Eq nor Ord: 0.1 + 0.2 is not 0.3 and never was. A ledger cannot be built on a type whose equality is a trap.

A scaled integer: a significand counted in steps of 10^-scale. Decimal::scaled(1, 2) is one hundredth. That is the representation SQL NUMERIC, Java’s BigDecimal and .NET’s decimal all approximate, and it is exact for anything written in decimal — which is what money is.

A hundred and twenty-eight bits of significand, carried as two Int fields because Khora has no 128-bit integer and adding one to hold a library type would be a large language change for a small gain. The halves are never arithmetic here: every operation on them happens in the runtime, where the significand is one number.

Sixty-four was tried first, on the argument that eighteen significant digits is every currency amount anybody transacts. True about amounts and false about arithmetic on them. add brings both operands to the larger scale before adding, so a hundred million against a rate to twelve places needed 1e10 × 10^10 against a ceiling of 9.22e18 and stopped the program. That is unremarkable finance. mul is worse, because it adds the scales, which is what makes it exact.

A hundred and twenty-eight covers that, NUMERIC as real schemas declare it, and an eighteen-decimal token balance — where sixty-four bits ran out at 9.2 whole units.

It stops the program, like every other number in Khora. numbers.md §“Overflow traps, in every build” argues that case and none of the argument changes here: a total that silently wrapped is worse than one that never arrived. That covers add, sub, mul, at_scale and divide alike.

Division is not an exception to it. It takes a scale and a rounding because a quotient usually has no exact decimal form, and answers None when the divisor is zero because that is a thing data does — but an answer too large for the significand stops the program, the same as anywhere else. It does not saturate: a saturated quotient is in range, printed to the right number of places, and wrong.

The three that cannot stop the program are comparing, reading and printing, and each was made not to. Comparing is a question about order and needs no common scale to exist. Reading text that is not a number — including a numeral too long to hold — is what of_string’s Option is for. And show is string work, so it prints every scale a Decimal can carry rather than the first eighteen.

Types: Decimal · Rounding

Methods: Decimal

Trait implementations: Eq for Decimal · Ord for Decimal · Show for Decimal

pub type Decimal = {
hi: Int,
lo: Int,
scale: Int,
};

A number counted in steps of 10^-scale.

hi and lo are one number in two halves, the upper and lower sixty-four bits of a signed hundred-and-twenty-eight bit significand. They are two fields because only scalars cross into the runtime — see The ffi design note — and they are never taken apart anywhere else.

The fields are private, and that is what makes scale never negative. The constructors clamp it, because a negative scale is a large number spelled confusingly and every caller who wants one means a larger significand; a literal from outside this module could skip the clamp, and is refused. scale() reads the scale, and hi and lo are the runtime’s business.

It is not bounded above, and nothing here pretends otherwise. Decimal::scaled(1, 20000) is a legitimate value: the significand is one and the point is twenty thousand places to its left. Nothing in std or in the runtime caps it, so no operation refuses one – but Show writes a character per place, and the arithmetic aligns operands to the larger of two scales before it works, so a wide scale is paid for in both. A scale arriving from input rather than from a literal is worth a bound of the caller’s own choosing; there is no obvious one to impose here, and imposing a wrong one would silently change values that are correct today.

pub type Rounding =
| HalfEven
| HalfUp
| Towards;

How divide breaks a tie.

| HalfEven

To the nearer, and to the even one when exactly between.

The default, and the one every accounting standard names: rounding halves up biases a long column of figures upward, and over a ledger that bias is money.

| HalfUp

To the nearer, and away from zero when exactly between. What most people mean by “round it”.

| Towards

Towards zero, always. What a truncation is.

impl Decimal
pub fn scale(self) -> Int

How many places follow the point. Never negative.

pub fn of_parts(hi: Int, lo: Int, scale: Int) -> Decimal

A significand given as its two halves, counted in steps of 10^-scale.

The total constructor, and the widest one: it is the only way to build a Decimal whose significand does not fit an Int, which is why a 0.01d literal desugars to this and why a decoder reading a 128-bit NUMERIC column wants it. Both halves are already numbers, so it cannot fail.

A negative scale is refused by clamping to zero rather than by raising, since it can only arise from a caller that computed it.

pub fn of_int(units: Int) -> Decimal

A whole number.

pub fn scaled(units: Int, scale: Int) -> Decimal

units counted in steps of 10^-scale.

The constructor for a significand that fits an Int, which is nearly all of them; of_parts is the one for the rest.

pub fn zero() -> Decimal

Zero.

pub fn at_scale(self, scale: Int) -> Decimal

The same number at a larger scale.

Exact, and stops the program if the significand cannot hold it — going from two decimal places to thirty-eight is a multiplication by ten to the thirty-sixth, and most numbers do not survive it. A smaller scale is answered unchanged, because dropping places is rounding and rounded is where a caller says how.

pub fn add(self, other: Decimal) -> Decimal

The sum. Exact, and stops the program if it does not fit.

Both operands come to the larger of their two scales first, which is the step that made sixty-four bits too narrow: the operands fit and their aligned forms did not.

pub fn sub(self, other: Decimal) -> Decimal

The difference. Exact, and stops the program if it does not fit.

pub fn mul(self, other: Decimal) -> Decimal

The product, exactly.

The scales add, which is what makes this exact and also what makes it overflow sooner than people expect: two numbers with four decimal places each give one with eight, and the significand has to hold all of them. rounded afterwards is how a caller comes back to a sensible scale.

pub fn divide(self, other: Decimal, scale: Int, how: Rounding) -> Option<Decimal>

The quotient, to scale decimal places, rounded as asked.

The one operation that cannot be exact, so it is the one that makes the caller say what it wants. A quotient with no finite decimal form — one third — has to be rounded somewhere, and a library that picked silently would be picking on behalf of a ledger.

None when other is zero. Not a trap, because dividing by a quantity that turned out to be zero is an ordinary thing for data to do, and numbers.md reserves stopping the program for a bug.

Stops the program when the answer does not fit, which is a different thing and gets the different treatment. There is no honest answer to give: the nearest one is a different number, and handing back a different number is the failure this whole type exists to prevent. rounded goes through here, so it stops too.

A negative scale is clamped to zero, as it is in every constructor.

pub fn rounded(self, scale: Int, how: Rounding) -> Decimal

The same number at scale places, rounded as asked.

The way back from mul’s widening scales, and the way a total becomes a figure somebody can be invoiced for.

A negative scale is clamped to zero, the way of_parts clamps it, because a Decimal has no scale below nought to be written at. This is the one call where that is likely to catch somebody out: rounded(-2) reads as to the nearest hundred and answers the nearest unit, with no Option in the signature to say it did something else. Round to the nearest hundred by dividing by 100d at scale zero and multiplying back.

pub fn negate(self) -> Decimal

The number with the opposite sign.

Subtraction from zero rather than a negation of its own, because the two halves are not a number this language can negate — and because the one significand with no positive twin should stop the program rather than answer itself.

pub fn is_negative(self) -> Bool

Whether it is less than zero.

pub fn zero_at(scale: Int) -> Decimal

Zero, written to scale places.

zero() is scale nought and prints as 0, which is wrong at the bottom of a column of 1250.00s – and a total that starts from Decimal::zero() and adds nothing keeps that scale all the way to the report. This is the empty total for a column that has a shape.

pub fn abs(self) -> Decimal

The magnitude, keeping the scale.

Stops the program on the one significand with no positive twin, for the reason negate does: there is no honest answer, and the alternative is to return the negative number it was handed.

pub fn min(self, other: Decimal) -> Decimal

The smaller of two, by value rather than by representation.

So 1.0d and 1.00d are the same number and either may come back; the left one does, which is the choice List::min makes for the same reason.

pub fn max(self, other: Decimal) -> Decimal

The larger of two, and the left one on a tie.

pub fn is_zero(self) -> Bool

Whether it is exactly zero.

At any scale: zero has both halves clear however many places it was written to.

pub fn truncated(self) -> Int

The whole part, towards zero.

Stops the program if the whole part is wider than an Int, which is the one place a Decimal has to become one and may not fit. Past thirty-eight places there is no whole part to find and the answer is zero.

pub fn total(numbers: List<Decimal>) -> Decimal

Every one of them added up.

The column total, which every ledger writes and nobody should write twice. add takes the larger of two scales, so a total of numbers written to two places is written to two places, and one containing a twelve-place rate carries twelve.

An empty list totals zero(), which is scale nought and prints as 0 – under a column of 1250.00s that is the wrong shape, and Decimal::at_scale(Decimal::total(rows), 2) is the fix. at_scale only ever raises, so it cannot round a total that was already wider.

Stops the program if the sum does not fit, like every addition here.

pub fn of_string(text: String) -> Option<Decimal>

A decimal written out, or None if the text is not one.

Accepts a leading sign, digits, and at most one point. Rejects everything else on purpose, including the exponent notation Float takes: a number arriving as 1e-3 is a measurement that has been through a float somewhere, and taking it here would launder that history.

The exact grammar, because “a leading sign” and “at most one point” each hide a case somebody meets:

  • + is a leading sign here. Int::of_string refuses one, and says why at some length – so the two disagree, and a program reading +5 out of a configuration file gets a number from one and None from the other. Neither behavior is being changed here, because changing either is a compatibility decision about a published parser rather than a repair; what is fixed is that the disagreement was written down in neither place.
  • The point may be leading or trailing. "5." is five and ".5" is a half, both accepted.

A numeral too long for the significand is a rejection too, which is what the Option is already there for. This is the function that reads numbers out of files somebody else wrote, and one long cell must not kill the process.

impl Eq for Decimal
fn eq(self, other: Decimal) -> Bool

By value, not by representation. 1.0 and 1.00 are the same number written twice, and a ledger that thought otherwise would report a difference where there is none. This is the whole reason Float cannot have an Eq and this can.

And it cannot stop the program. Bringing both operands to a common scale, the way add does, would ask for a multiplication by 10^12 to compare a hundred million against a rate to twelve places — which no significand survives, so two perfectly legal numbers would halt a reconciler. An equality that traps is a worse trap than one that surprises, and this one is sold as the reason to prefer Decimal at all.

impl Ord for Decimal
fn cmp(self, other: Decimal) -> Ordering

The same comparison, and the same reason it does not go through add’s alignment.

impl Show for Decimal
fn show(self) -> String

Every place the scale says, including trailing zeros.

Decimal::scaled(150, 2) shows as 1.50 and not 1.5, because the scale is part of what was meant: a price to two places stays a price to two places, and dropping the zero is how a total stops lining up in a column.

Done as text rather than as arithmetic, which is what lets it print every number a Decimal can hold. Splitting the significand with units / 10^scale needs 10^scale to be a number, and past the widest power it is not — so mul on two numbers with twenty places each produced a value that was perfectly legal and stopped the program when printed. Cutting a string at a position needs nothing to fit.

It also means the most negative significand prints. Negating it to take a magnitude would overflow, so the runtime writes the magnitude and the sign is a character here.

Khora 0.4.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource