2. Numbers and money
What this is for
Nobody writes a money bug on purpose. It arrives like this.
> 19.99 * 3
59.96999999999999
The screen shows 59.97, because whatever formats it rounds to two places. The ledger stores
59.96999999999999. A month later reconciliation is off by a cent and three people spend an
afternoon on it.
That one is famous enough that most people guard against it. Here is the version that gets through review, which is the one this page is really about:
subtotal = 59.97 # two decimal places
rate = 0.0825 # a tax rate
tax = subtotal * rate # 4.947525 — six decimal places
store(tax) # the column holds two
Nothing looks wrong. There is no float error, no overflow, no exception. The product genuinely has six decimal places and the column genuinely holds two, and something has to give — so a language that stores it anyway has silently decided how to round on your behalf. Do it a hundred thousand times and the difference is a number an auditor will eventually ask about.
A silent wrong answer is the worst outcome a program can produce. Worse than a crash, which you find on Tuesday. Far worse than a refusal, which you find in the compiler.
Think of coins in a jar, not water in a jug
Water is the wrong shape for money. Pour it between glasses and a little stays behind every time — not because you were careless, but because that is what pouring is. A float is water.
Coins are countable. Move 1999 pennies from one hand to the other and you have 1999 pennies, because there was never anything smaller than a penny to lose.
Decimal<2> is 1999 and a label saying how many of these make one. Nothing
divides, so nothing is lost — and adding two different labels has no answer to give, which is why
Burxt refuses it where a float language quietly picks one.That is the whole idea. There is no 19.99 anywhere in a Burxt program’s memory: there is the whole
number 1999 and a scale, and the scale lives in the type.
A step closer
Decimal<2> and Decimal<4> are different types, the way Int and Bool are different types.
The compiler will never quietly turn one into the other, because there is no answer to turn it into:
| You write | Stored as | The label | So it means |
|---|---|---|---|
19.99 |
1999 |
2 | 1999 / 10² = 19.99 |
$19.99 |
1999 |
2 | the same value — the $ is documentation, not a currency |
8.25% |
825 |
4 | 825 / 10⁴ = 0.0825 |
42 |
42 |
— | Int, signed 64-bit, and it traps on overflow |
8.25% being a Decimal<4> surprises everyone exactly once. It is arithmetic, not a rule: 8.25
percent is 0.0825, and 0.0825 needs four places.
Add 1999 and 825 and you get 2824. Is that 28.24 or 0.2824? There is no answer — and that
is not a limitation of the compiler, it is a fact about the question. Every language that returns a
number here has picked one on your behalf without writing it down.
A literal takes the scale of its context when that loses nothing: let x: Decimal<2> = $5; is
5.00. When it would lose something, it is refused rather than rounded.
In code
Adding needs matching labels.
function total(price: Decimal<2>, rate: Decimal<4>) -> Decimal<2> {
return price + rate;
}
error: cannot + Decimal<2> and Decimal<4>: scales must match. Burxt does not
silently rescale money.
Multiplying is the opposite case. Multiplying an amount by a rate is the normal thing to do, and their scales differ by definition. Two places times four places is an exact product with six, so landing it on two throws four digits away — and the type has to say which way:
let price: Decimal<2> = $19.99;
let rate: Decimal<4> = 8.25%;
let tax: Decimal<2, RoundHalfEven> = price * rate; // 1.65
Leave the contract off and you are told, with both numbers and both ways out:
error: this multiplication of Decimal<2> by Decimal<4> has an exact product with 6
decimal places, and reaching Decimal<2> means rounding it. Say how —
Decimal<2, RoundHalfEven> — or take the exact answer with Decimal<6>.
Or keep every digit, which needs no contract at all, because nothing rounds:
let price: Decimal<2> = $19.99;
let rate: Decimal<4> = 8.25%;
let exact = price * rate; // Decimal<6> — 1.649175, all of it
let same: Decimal<6> = price * rate; // the same thing, written down
The two contracts are RoundHalfEven — banker’s rounding, the default in most financial regulation
because it does not bias upward across many transactions — and RoundHalfUp.
Division always needs one, even when the scales already match, because a quotient can land
between two representable values: 1.00 / 3 has no exact answer at two places, and no scale you
could pick would give it one.
Where the contract goes
Where the rounding happens. Not where the money enters:
let price: Decimal<2> = $19.99; // no contract needed
let subtotal: Decimal<2> = price * 3; // exact, none needed
let rate: Decimal<4> = 8.25%;
let tax: Decimal<2, RoundHalfEven> = subtotal * rate; // ← here, where it rounds
let total: Decimal<2, RoundHalfEven> = subtotal + tax;
Two things make that read the way you would hope.
A contract may be added where a value has none. Decimal<2> and Decimal<2, RoundHalfEven>
hold the same integer. A contract does not reinterpret a value; it constrains what future
operations are allowed to do to it. So it can arrive at the binding that needs it.
Adding and subtracting need matching scales, not matching contracts. They never round, so one side carrying a rule and the other not leaves exactly one answer to “if this ever rounds, which way” — and the result carries it.
A rounding rule travels
Once a value carries RoundHalfEven, so does every signature it flows through:
function line_tax(subtotal: Decimal<2>, rate: Decimal<4>) -> Decimal<2, RoundHalfEven> {
return subtotal * rate;
}
That looks like a cost and is the entire mechanism. A reviewer reading the caller can see how this
rounds without opening the file it is defined in — and an agent cannot change the rounding of a total
without changing a declaration, which is a thing
burxt review can find.
Why it is built this way
Three properties fall out of “an integer and a scale, both in the type”, and each one is worth the strictness on its own.
A wrong answer cannot be plausible. The failure mode this replaces is not a crash; it is a number that looks right. Refusing at compile time moves the discovery from an auditor’s question to a message on your screen, and those are not the same afternoon.
It is faster, not slower. Scaled-integer arithmetic is integer arithmetic. There is no decimal library, no allocation, and no floating-point unit involved — products are computed in 128 bits internally so the check is on the answer rather than on an intermediate step.
The rounding is in the signature, so a reviewer can see it. This is the part that connects to the
rest of the language. Decimal<2, RoundHalfEven> in a return type means somebody decided, here, how
this total rounds — and you learn it from the declaration without reading a body. A rounding rule
hidden in a function body is a rule that can be changed in a diff nobody reads.
What it costs
Honestly: some annotations you would not have written in Python.
You will write Decimal<2, RoundHalfEven> where another language wrote nothing. That is the whole
bill. It is paid exactly where a value narrows, which is also exactly where the decision is — but it
is still typing you did not do before.
8.25% is a Decimal<4> and that surprises everybody once. It is correct and it is still a
surprise.
A scale is fixed at 18 places. Beyond that there is no type to hold it.
A Decimal has no display form you can define. to_string handles Int, Bool and Decimal,
and a class of yours cannot have one — there is no interface for it yet. That is a gap rather than a
decision, and it is recorded as one in the
reference.
Nothing here helps at the boundary unless you use it. Guarding the arithmetic and then handing
the value to a C function taking a double guards nothing. as scaled is how a Decimal crosses to
C without becoming a float — see the C boundary.
When you reach for it
| You are writing | Reach for |
|---|---|
| a price, a total, a balance, a fee | Decimal<2> |
| a tax rate, an interest rate, a discount | Decimal<4>, usually written 8.25% |
| a subtotal that will be multiplied by a rate later | Decimal<2> — add the contract where it narrows, not here |
| the result of amount × rate, stored in a money column | Decimal<2, RoundHalfEven> |
| the result of amount × rate, kept for a later step | Decimal<6> — exact, no contract, nothing rounds |
| a count, an index, a quantity | Int |
| anything a model or a form handed you | String until you parse it — see absence and failure |
The rule of thumb: narrow once, as late as you can. Every rounding is a decision, and a program with one rounding in it is a program with one decision to review.
Examples
A till line, all the way through. Every number below came from running this program.
let price: Decimal<2> = $19.99;
let quantity: Int = 3;
let subtotal: Decimal<2> = price * quantity;
let rate: Decimal<4> = 8.25%;
let tax: Decimal<2, RoundHalfEven> = subtotal * rate;
let total: Decimal<2, RoundHalfEven> = subtotal + tax;
print(subtotal);
print(tax);
print(total);
59.97
4.95
64.92
59.97, not 59.96999999999999. And 4.95 is a rounding somebody named: the exact product is
4.947525, and RoundHalfEven in the type is where the decision was written down.
Exact, or narrowed — you choose, and the type says which.
let price: Decimal<2> = $19.99;
let rate: Decimal<4> = 8.25%;
let exact: Decimal<6> = price * rate;
let rounded: Decimal<2, RoundHalfEven> = price * rate;
print(exact);
print(rounded);
1.649175
1.65
And an integer that will not wrap. Int is signed 64-bit; push a result past what it holds and
the program stops rather than continuing with a negative number:
let big: Int = 9223372036854775807;
print(big + 1);
burxt runtime error: arithmetic overflow — the exact result no longer fits in the value range
In C, C#, Java and Go that same expression quietly becomes a negative number.
/ on two Ints is a compile error for the same reason division of money needs a contract — one
operator cannot say which way to round. divide_floor(-7, 2) is -4; divide_toward_zero(-7, 2)
is -3. They differ, they differ silently in most languages, and here you say which you meant.
Next
Types — classes, private, constructors, interfaces, enums, and why there is no
inheritance.