How decimals feel next to number, integer and vector¶
2026-10-18
A new value type is only worth having if people can write ordinary code with
it. This article is about that: how much of the language works on a decimal,
where it stops, and whether the stopping points are sensible or accidental. I
compare it with the three value types it will sit beside, number, integer
and vector, using output from the
demo suite
and not my memory of how Luau behaves.
The same code, with and without¶
Money is the case that motivated all this, so start there. An invoice with four lines and 24 % VAT, written with plain numbers:
local lines = { {19.99, 3}, {4.95, 12}, {0.333, 100}, {85, 1.5} }
local net = 0
for _, l in lines do net += l[1] * l[2] end
print(net) -- 280.17
print(net * 0.24) -- 67.24080000000001
local function round2(x) return math.floor(x * 100 + 0.5) / 100 end
print(round2(net * 0.24)) -- 67.24, but the double is 67.239999999999995
and with decimals:
local lines = { {19.99d, 3d}, {4.95d, 12d}, {0.333d, 100d}, {85d, 1.5d} }
local net = 0d
for _, l in lines do net += l[1] * l[2] end
print(net) -- 280.170
print(net * 0.24d) -- 67.24080
print(decimal.round(net * 0.24d, 2)) -- 67.24
The code is the same shape. What changed is that the values printed are the ones a person would write on the invoice, and that the rounding step is a named operation with a documented rule instead of a formula you have to trust.
That last point is worth a second look, because "round to cents" is the function everyone writes by hand. Here are two common ways to do it with doubles, on numbers that sit on a half:
| Input | floor(x*100 + 0.5)/100 |
string.format("%.2f") |
decimal.round(x, 2) |
decimal.round(x, 2, "awayfromzero") |
|---|---|---|---|---|
| 2.675 | 2.68 | 2.67 | 2.68 | 2.68 |
| 1.005 | 1 | 1.00 | 1.00 | 1.01 |
| 0.285 | 0.28 | 0.28 | 0.28 | 0.29 |
Two methods that are supposed to do the same thing disagree on 2.675, and neither of them gives the schoolbook 1.01 for 1.005, because the double nearest to 1.005 is slightly below it. The decimal column is not magic either: the default is half-even (banker's rounding), so a true half goes to the even neighbour and 1.005 becomes 1.00. The difference is that the rule is written down, applies to the value you typed, and can be changed to the schoolbook one with one argument. That is the ergonomic win in one table: the answer does not depend on which idiom you happened to reach for.
What works on each type¶
I ran the same operations against all four types. A tick means the operation succeeds, a cross means it raises an error.
| Operation | number | integer | decimal | vector |
|---|---|---|---|---|
a + a, a - a, a * a, a / a |
✓ | ✗ | ✓ | ✓ |
a // a |
✓ | ✗ | ✗ | ✓ |
a % a |
✓ | ✗ | ✓ | ✗ |
a ^ a |
✓ | ✗ | ✗ | ✗ |
-a |
✓ | ✗ | ✓ | ✓ |
a < a, a <= a |
✓ | ✗ | ✓ | ✗ |
a == a |
✓ | ✓ | ✓ | ✓ |
a + 1 (a plain number) |
n/a | ✗ | ✗ | ✗ |
a .. "x" |
✓ | ✗ | ✗ | ✗ |
`{a}` and tostring(a) |
✓ | ✓ | ✓ | ✓ |
t[a] = 1 (as a table key) |
✓ | ✓ | ✗ | ✓ |
table.sort({a, a}) |
✓ | ✗ | ✓ | ✗ |
table.find({a}, a) |
✓ | ✓ | ✓ | ✓ |
math.max(a, a) |
✓ | ✗ | ✗ | ✗ |
string.format("%s", a) |
✓ | ✗ | ✗ | ✗ |
string.format("%d", a) |
✓ | ✓ | ✗ | ✗ |
Reading it as a design review:
- Decimal is much closer to
numberthanintegeris.integersupports equality and nothing else; arithmetic is the library (integer.add). Decimal has every arithmetic operator an amount of money needs, plus ordering, and so defaulttable.sortjust works. For code that is going to add up prices, that difference decides whether the type is usable at all. - Decimal has a different set of gaps from vector, and they are the right
ones. Vectors have no ordering and no
%; decimals do. Decimals have no//or^; there is no sensible decimal answer to2d ^ 0.5d, and a floor-divide isdecimal.trunc(a / b)ordecimal.floor. - Four things fail for no reason deeper than "not done yet":
..,math.*,%sand table keys. The first three are small, and I would add them before proposing anything. The fourth is hard (see below).
One row has a story. table.find({a}, a) was the only operation where decimals
did not just fail, they failed quietly: it returned nil for a value that
was in the table. The comparison above is how I noticed, and it is fixed. An
unsupported operation should raise, and a supported one should work; "returns
the wrong answer" is the only unacceptable option.
The mixing rules¶
Decimals mix with integer and never with number:
print(1.5d + 2i) -- 3.5
print(2i * 0.5d) -- 1.0
print(1.5d < 2i) -- true
print(1.5d + 1) -- error: attempt to perform arithmetic (add) on decimal and number
print(1.5d < 2) -- error: attempt to compare decimal < number
The reasoning is the same as .NET's: an int converts to a decimal without
loss, a double does not. The type system enforces it as well (see below), so
the error shows up in the editor and not at run time.
The rule has one uncomfortable corner. Equality is strict about types, as it is everywhere in Luau, so:
print(1d < 1.5d, 1i < 1.5d) -- true true ordering mixes with integers
print(2d == 2i) -- false equality does not
print(2d <= 2i and 2d >= 2i) -- true
<= and >= see 2d and 2i as equal and == does not. Equality has never
crossed types in Luau (1 == "1" is false), and making it do so here would mean
changing the EQ opcodes and their native-code versions, so I left it. But
ordering across decimal and integer is new, which makes this the one place
where I think a reader could be surprised; it should be in the documentation in
bold.
What the errors say¶
A new type has to fail legibly. These are the messages from the error catalog
(12-errors in the suite), exactly as printed:
decimal + number attempt to perform arithmetic (add) on decimal and number
decimal < number attempt to compare decimal < number
decimal .. string attempt to concatenate decimal with string
decimal // decimal attempt to perform arithmetic (idiv) on decimal
1d / 0d decimal division by zero
9223372036854775807d * 2d decimal overflow
t[1d] = 1 table index is a decimal
decimal.round(1d, 29) invalid argument #2 to 'round' (number of places out of range [0, 28])
They name both operand types, which is the useful part when one of them is a
number that came from somewhere else. The weakest message is the last of the
arithmetic ones: decimal // decimal says "on decimal", which is accurate and
unhelpful. There is no "did you mean decimal.trunc(a / b)", and I would not
add one to the VM; that belongs in documentation.
In the editor¶
The type checker treats decimal as a primitive, like integer, and the
operators are declared as metamethods on it. The behavior in a strict file
(from 13-typing):
--!strict
local a: decimal = 1.5d
local i: integer = 2i
local n: number = 2
local ok1: decimal = a + i -- fine, in either order
local ok2: boolean = i <= a -- fine
local e1 = a + n -- Operator '+' could not be applied to operands of
-- types decimal and number; there is no corresponding
-- overload for __add
local e2: number = a + a -- Expected this to be 'number', but got 'decimal'
local e3 = a // a -- no corresponding overload for __idiv
and the refinement works as for any primitive:
local function describe(x: decimal | number | string): string
if type(x) == "decimal" then
return decimal.tostring(x) -- x is a decimal here
end
return tostring(x)
end
Both the new and the old type solver pass the same tests. Writing this I found
that the linter flagged type(x) == "decimal" as an unknown type name; that was
a bug in the prototype and is fixed. (integer has the same warning today; I checked. It is not mine to change, but
it is the kind of thing a new primitive type finds.)
The costs of the design that you can feel¶
Four things are visible to an everyday user, and I think they are worth saying plainly rather than discovering in review.
Scale is part of what you print. 2.50d and 2.5d are equal and print
differently. That is exactly what you want for an invoice line and a surprise
for someone who expects a number. It follows .NET, and I think it is right, but
it means tostring(a) == tostring(b) is not a way to test equality.
There is no implicit string conversion. "total " .. d fails; interpolation
works, and so does tostring. This is consistent with integer, and with a
type that has to decide how many digits to show, but .. is the first thing
anyone will type.
Decimals cannot be table keys. Using one raises an error. Supporting it is
possible: the hash would have to ignore the scale, so that 2.50d and 2.5d
land in one slot, and key comparison would have to be numeric. But the table
implementation has several specialised lookup paths for each key type, and I
did not want to touch all of them in a prototype or let a table silently hold
two entries for what == calls one key. The workaround is tostring plus a
convention about scale, or decimal.round to a fixed scale first. This is the
limitation I would expect people to push back on.
Everything numeric in the standard library refuses it. math.max,
math.floor, string.format("%.2f") and the rest take numbers. The decimal
library has its own min, max, floor, ceil, trunc and round, and
decimal.tonumber is the bridge, but code that formats currency with
string.format has to convert first. Whether string.format should learn
about decimals is a question for the people who own it.
What it feels like¶
My honest summary: for the thing it is for, adding up and splitting amounts of
money and printing the result, it is comfortable, and noticeably better than
integer, because it has operators. For everything else around that core, it
feels like a library type that grew an operator set, which is roughly what it
is. The gaps are all in the standard library's edges and none are in the type's
core.
What I would do before showing this to anyone who owns Luau:
- Make
..andstring.format("%s")work, or decide that they should not and say why. - Decide the table-key question deliberately instead of by default.
- Document the
==versus<=asymmetry, in bold.
The remaining article is the maintainer's view: what it took to build, file by file.
Reproducing¶
git clone https://github.com/vivainio/luau && cd luau
git checkout decimal-type
make luau luau-analyze config=release
./demos/decimal/run.sh
The operation table comes from a small probe that runs each operation in a
pcall against each type; the examples are the demos 04-arithmetic,
08-mixing, 10-money, 11-containers, 12-errors and 13-typing.
Pinned to the decimal-type branch of my fork at the time of writing.