Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite

Decimal Numbers#

Status: decided (2026-10-12), built in four slices (the last, #336, on 2026-10-28). — Decimal is declared in std/number (native type Decimal with Signed, Hash), both back ends give it a value, and section 11 is built: JSON, YAML and TOML keep every digit. This record fixes what the type is, so that the three slices of section 14 build one specification and not three guesses. It is a numeric design choice inside the language: the type has never run, so no published contract changes.

A Decimal is an integer with the decimal point written beside it, and the arithmetic that cannot lose a digit never does. 0.1 + 0.2 is 0.3, 19.90 * 3 is 59.70, and the one operation that cannot be exact - a division with an endless expansion - rounds in a way the program can read and can override. That is what money, tax, measures in a database column and the numeric of every SQL driver need, and what no binary floating-point type can promise.

   19.90                      -0.0050                    1000 written 1e3
   ┌────────────┬─────┐       ┌────────────┬─────┐       ┌────────────┬─────┐
   │ unscaled   │scale│       │ unscaled   │scale│       │ unscaled   │scale│
   │   1990     │  2  │       │    -50     │  4  │       │     1      │ -3  │
   └────────────┴─────┘       └────────────┴─────┘       └────────────┴─────┘
   value = unscaled / 10^scale:  1990 / 100            -50 / 10000            1 / 10^-3

1. What is decided#

  • The value is an unscaled integer of any size and an Int32 scale (section 2), as Java's BigDecimal and Python's decimal have it. Postgres numeric, MySQL DECIMAL and BSON Decimal128 all fit without loss.
  • +, - and * are exact. The scale of a sum is the larger scale, of a product the sum of the scales (section 5). Only the scale leaving Int32, or memory, panics.
  • / rounds to 34 significant digits, half to even, when the exact quotient has more; it panics on a zero divisor like Int. % is the truncated remainder with the sign of the dividend, exact (section 6).
  • Rounding is explicit where the program chooses it: rounded(scale:, rounding:) and divided(by:, scale:, rounding:) take one of six modes (section 7).
  • Equality is by value: 1.0 == 1.00. compare and hash agree with it, Show keeps the scale (section 3 and 4).
  • A literal keeps every digit it was written with and never passes through Float64 (section 9). Conversions from and to Float are explicit, and the one from Float can fail (section 8).
  • Seed: none expected. The compiler does not use Decimal, and its natives are declared already (section 10).

2. The value and its range#

A Decimal is a pair: an unscaled integer (a sign and a magnitude of any size) and a scale (an Int32). Its value is unscaled / 10^scale.

  • The scale may be negative. 1e3 is 1 with scale -3 and 1000 is 1000 with scale 0: equal values, two forms. A database column has a scale of zero or more, and Decimal128 has a negative exponent as well as a positive one, so the model has to hold both without loss.
  • The scale is part of the form, not of the value. 19.9 and 19.90 are equal and are different Decimals to Show (section 3), to scale() and to a database that stores the scale. Arithmetic never drops a scale it did not have to (section 5).
  • Zero has no sign. -0.00 is 0.00, and Decimal.zero - Decimal.zero is not "negative zero" in any form. There is no nan and no infinity, which is the reason a Decimal is Hash and a Float is not.
  • There is no limit on the magnitude and none on the number of digits, except memory: a result that does not fit panics like every out-of-memory panic does. The scale is the one bounded part: an operation whose result scale is below Int32.minimum or above Int32.maximum panics with decimal scale out of range in `*` (the operator's own name). Text and literals whose scale does not fit are refused instead (section 4 and 9).
  • There is no context. Python's global precision and rounding, and Java's MathContext, are not here: the one precision is the 34 digits of / (section 6), and every other rounding is an argument of the call that rounds.

3. Equality, order and hash#

  • ==, < and compare are by numeric value. 1.0 == 1.00, compare(1.10, 1.1) is equal, and -0.0 (which reads as 0.0) equals 0.00.
  • hash is consistent with equality: equal values hash equal whatever their scale. It is the hash of the normalised form - the unscaled integer with its trailing zeros removed and the scale lowered to match, so 19.90, 19.9 and 19.900 are 199 with scale 1 - as torb_hash_bytes (FNV-1a-64, a fixed seed) of that form's Show text. Zero hashes as the text 0.
  • A Map<Decimal, V> therefore has one key for 1.0 and 1.00, and the key it keeps is the one that was inserted first, with its own scale.
  • Show is the one place the scale is visible: 19.90 stays 19.90 and prints with its zero. A program that wants the form without the zeros calls normalized() first (section 10).

4. Text: Show and TryFrom<String>#

show() writes the value in plain notation, never with an exponent:

show   ::= "-"? integer ("." fraction)?
  • integer is the digits of the unscaled integer left of the point, at least one (0.05 has a 0).
  • With a scale above zero, the point and exactly scale digits follow, zeros included: 19.90, 0.05, -0.5000.
  • With a scale of zero, no point: 42. With a negative scale, the unscaled digits followed by -scale zeros: 1 with scale -3 is 1000.
  • Zero is 0, 0.00, never -0.00. A Decimal inside a generated Show (Price(amount: 19.90)) and in an interpolation ("{price}") is this text, unquoted.

Decimal.tryFrom(text) reads this grammar, and show() output always reads back to an equal value with the same scale (for a scale of zero or more):

text     ::= sign? digits ("." digits)? exponent?
sign     ::= "+" | "-"
digits   ::= digit+
exponent ::= ("e" | "E") sign? digits
  • The scale is the number of digits after the point minus the exponent: "12.5E-3" is 125 with scale 4. Leading zeros are read and dropped ("007" is 7).
  • It refuses with NumberParseError (the text, unchanged): the empty text, whitespace anywhere, a missing digit on either side of the point (".5", "5."), an exponent without digits, nan, inf, a digit separator ("1_000"), a hexadecimal or binary prefix, a thousands separator or a decimal comma, and a scale that does not fit Int32 ("1e2147483648", "1.5e-2147483647").
  • The grammar is the grammar of a number literal, without the digit separator (section 9). It is deliberately narrower than Float64.tryFrom, which is strtod: a money amount must not read "inf".

5. Addition, subtraction, multiplication#

  • a + b and a - b are exact. The unscaled integers are brought to the larger of the two scales (19.90 + 0.1 is 20.00, the scale of the first), then added. The result keeps that scale even where it ends in zeros.
  • a * b is exact. The unscaled integers multiply and the scales add: 1.5 * 2.00 is 3.000, 19.99 * 3 is 59.97.
  • -a and a.absolute() keep the scale, and negating zero is zero.
  • The scale of a result outside Int32 panics (section 2); so does a result that does not fit memory, which a scale difference of a few billion digits (1e-2000000000 + 1) reaches.
  • +, - and * never round and never lose a digit. A program that wants fewer digits calls rounded (section 7).

6. Division and remainder#

An exact division has no shape that always works: 1 / 3 has no end. Three ways out exist (Java throws, Python rounds to a global precision, Go's shopspring to a global 16 digits); this language does the Python thing with a fixed, visible precision and no global.

  • a / b panics on b == 0 with division by zero in `/` (PANICS.md), as Int does.
  • Otherwise the exact quotient is looked at. Let p = 34 (the precision of Decimal128) and s0 = scale(a) - scale(b), the preferred scale.
    • If the exact quotient has at most 34 significant digits, the result is exact. Its scale is the larger of s0 and the smallest scale that holds the quotient (its scale after trailing zeros are removed; zero counts as scale 0), as long as the result keeps at most 34 significant digits. So 10 / 4 is 2.5, 6.0 / 3 is 2.0, 1.00 / 4 is 0.25, 1.00000 / 4 is 0.25000, 100 / 4.0 is 25.
    • Otherwise it is rounded to 34 significant digits, half to even, and not stripped: 1 / 3 is 0.3333333333333333333333333333333333, 2 / 3 ends in 67.
  • a % b is the truncated remainder, exact: a - b * trunc(a / b), with the sign of the dividend, as for Int (-7 % 2 is -1). Its scale is the larger of the two scales (-5.50 % 0.4 is -0.30); a zero remainder is zero. x % 0 panics with division by zero in `%` . The quotient of the definition is a whole number and is not limited to 34 digits, so the remainder is always exact.
  • For a result of a chosen shape, divided(by:, scale:, rounding:) (section 7) takes the scale and the mode and rounds the exact quotient once. / is for "the quotient, as exact as is useful"; divided is for "cents" and "basis points".

7. Rounding#

/** How a value is cut where a digit has to go. The six modes are final: no case is added within 1.x. */
public type Rounding {
  /** To the nearest, a tie to the neighbour with an even last digit. The mode of `/`, banker's rounding. */
  case HalfEven
  /** To the nearest, a tie away from zero: `2.5` is `3` and `-2.5` is `-3`. What a person at a till does. */
  case HalfUp
  /** Towards zero: the digits are cut off. */
  case Down
  /** Away from zero: any dropped non-zero digit raises the magnitude. */
  case Up
  /** Towards negative infinity. */
  case Floor
  /** Towards positive infinity. */
  case Ceiling
}
  • rounded(scale: Int32, rounding: Rounding): Decimal returns the value with exactly that scale. A scale above the current one adds zeros and rounds nothing (2.5 at 3 is 2.500); a scale below drops digits by the mode; a negative scale rounds to tens, hundreds (1250 at -2, HalfEven, is 1200).
  • divided(by: Decimal, scale: Int32, rounding: Rounding): Decimal returns the exact quotient rounded once to that scale. It panics on a zero divisor like /. It never rounds twice: 1 / 3 at scale 2 with Up is 0.34, not the 0.33 of the 34-digit quotient rounded again.
  • HalfDown and the other tie-breaking modes are not offered. The six cover the ones that databases, accounting rules and Float.round (halves away from zero, here HalfUp) name; a seventh can be added as a method on Rounding without a new case.
  • There is no round(), floor() or ceiling() on Decimal: those names are the Real trait's, and a Decimal is not Real (no square root, no angle). rounded(scale: 0, rounding: .Floor) is the floor.

8. Conversions#

From → toSpelledRule
Int64, UInt64 → DecimalDecimal.from(42)infallible, scale 0
Decimal → Int64, UInt64Int64.tryFrom(price)NumberRangeError unless the value is a whole number in range: 19.00 converts, 19.5 does not. Truncation is a decision, so it is spelled: price.rounded(scale: 0, rounding: .Down) first
Float64 → DecimalDecimal.tryFrom(0.1)NumberRangeError for nan and the infinities; otherwise the shortest decimal that reads back as that Float64, i.e. Decimal.tryFrom(x.show()) for a finite x: 0.1 is 0.1 and not the binary expansion 0.1000000000000000055…
Decimal → Float64Float.from(price)infallible and rounded: the nearest Float64, half to even, as reading the decimal's text as a Float64 gives; beyond the range inf or -inf, as a Float literal would, and no panic
  • No implicit conversion exists, as everywhere (CONCEPT.md). price * 2 types because the literal 2 adapts (section 9); price * quantity with an Int variable is price * Decimal.from(quantity).
  • Float32 has no direct conversion; it goes through Float64. The conversions are in std/number, beside the type.
  • The unscaled integer is reached as text, because the language has no big integer: unscaledText(): String (sign and decimal digits) with scale(): Int32, and Decimal.of(unscaled: String, scale: Int32) as the way back, a Result<Decimal, NumberParseError> that accepts a sign and digits only. A binary driver builds the wire form (Postgres sends base-10000 digits and a weight) from these two.

9. Literals#

  • A literal that adapts to Decimal keeps every digit it was written with. 19.99 is 1999 with scale 2, 19.90 keeps its zero, 1_000.50 is 1000.50, 1e3 is 1 with scale -3. It is read with the grammar of section 4 plus the digit separator _, and it never passes through Float64: const price: Decimal = 0.1 is exactly one tenth. A written scale that does not fit Int32 is a compile error.
  • An integer literal adapts as well, in any base: const n: Decimal = 255 and 0xFF are 255 with scale 0, as const z: Float = 1 is allowed (CONCEPT.md). A literal that has no expected type stays what it was: 19.99 a Float64, 5 an Int64.
  • -19.99 is 19.99 negated, scale 2. Decimal.zero and Decimal.one become 0 and 1 (scale 0), not the 0.0 and 1.0 they are written as in std/number today, so that Decimal.zero + 5 keeps the scale of 5.
  • A literal is a constant. Its value is built by the compiler's constant evaluator and emitted by both back ends as data (section 10), so a Decimal constant allocates nothing at run time.

10. Runtime representation and natives#

The IR already has the kind: RuntimeKind.DecimalNumber, laid out as pointerSize + 8 bytes in compiler/src/ir/layout.trb. That layout is kept:

 Decimal (inline, pointerSize + 8 bytes)
 ┌─────────────────────┬────────────┬───────────────────┐
 │ block pointer       │ Int32 scale│ Int32 limb count  │   count < 0: negative; 0: the value is zero
 └──────────┬──────────┴────────────┴───────────────────┘
            ▼   counted, immutable block (the header every counted block has)
            uint32 limbs, base 10^9, least significant first, the top limb non-zero
  • A value is the inline triple; the digits are a counted, immutable heap block that copying a Decimal retains and dropping releases, like the storage of a string. Zero has no block (a null pointer and a count of zero). Because the block is immutable, negate and absolute share the input's block, and rounded to a larger scale builds a new one.
  • Base 10^9 is chosen for the text, not for the speed of multiplication: the digits of show() and parse are the limbs, so a number is never converted between bases, and the rounding of a scale is a shift of limbs and one division of a single limb. Schoolbook multiplication and division on 32-bit limbs are enough (no big-integer library; the runtime has none and takes none).
  • An immortal static block (TORB_IMMORTAL_COUNT, runtime/include/torb.h) is what a literal is, which is why a constant needs no allocation (section 9).
  • A small value is not special-cased. An inline form for a unscaled value in 63 bits is an optimisation that no program can see; the layout would change, so it is not part of this record.

The natives, as torb_decimal_<member> in a new runtime/decimal.c, with their manifest entries in compiler/src/backend/c/natives.trb changing from Planned to Ready:

Planned today (12, decimalEntries)add subtract multiply divide remainder negate absolute equals compare show hash, and parse for tryFrom(String)
New in this recordrounded, divided, scale, normalized, unscaledText, of(unscaled:scale:), Decimal.from(Int64), Decimal.from(UInt64), Int64.tryFrom(Decimal), UInt64.tryFrom(Decimal), Float64.from(Decimal)
Written in TorbScript, no nativeDecimal.tryFrom(Float64) (over Float64.show and Decimal.tryFrom(String))
  • Float64.from(Decimal) is a native because Float64.tryFrom(String) copies into a 512-byte buffer and refuses a longer text. The native keeps at most 800 significant digits and one sticky digit for the rest, which is enough to round correctly.
  • normalized() removes the trailing zeros of the unscaled integer and lowers the scale to match (it may become negative); it is what hash is defined on (section 3).
  • Seed: none expected. The compiler does not compute with Decimal, and a native the compiler does not reach does not need the seed to know it. Slice #334 re-checks that with ir --statistics .: a Decimal native that the compiler's own program reaches would put the slice under the two-commit rule of CLAUDE.md ("Seed and breaking changes").
  • The VM reaches the same C functions through the table of thunks that torb natives generates from the manifest (VM.md section 6), so the arithmetic is written once.

11. Encoding and the database drivers#

  • EncodedValue gets case Exact(value: Decimal), which ENCODING.md section 7 declares and section 16 (item 3) leaves out until the runtime has a Decimal, where the exact number is held as its text today. Values.decimal, ValueDecoder.decimal and JsonEncoder.decimal stop being the three findings ir --statistics . counts for encoding.
  • JSON writes a Decimal as a number token with the show() text (19.90 stays 19.90) and reads a number into a Decimal from the token's text, never through Float64; 1e3 is read as 1000. A Decimal field of a derived Decode that meets a string refuses it, as every number field does. Describe says Exact, which a schema format renders as its number type (NUMERIC in SQL).
  • As built (#336). ValueDecoder.decimal reads an Exact as it is, an Int, Unsigned or Float through its text, and refuses a string unless the decoder is lenient, was built with exactAsText (TOML: no decimal type, so the Decimal is a string of its show() text) or the string is a map key. In a document without a type, JSON and YAML read a number token that a Float64 prints back (1.5) as a Float and any other (19.90, 1e3, 40 digits) as an Exact. A negative scale is written in plain notation, so 1e3 comes back as the same value at the scale 0. A Float64 or an Int64 field reads an Exact through Float64.from and Int64.tryFrom. std/sandbox writes an Exact as x19.90;. The conformance program is decimal-encoding.
  • The drivers: Postgres numeric and MySQL DECIMAL map one to one (the scale of the column is scale()). BSON Decimal128 holds 34 digits and an exponent from -6176 to 6111: a Decimal with more digits or a scale beyond that is refused by the driver with its own error, not rounded.

12. Worked examples#

The conformance programs of slices #334 to #336 copy these rows. Every result text was computed with Python's decimal module at 400 digits and a 34-digit context for /, and then corrected by the rules above where the two differ (zero has no sign here). a → b reads "the text of the result is b".

Reading text (Decimal.tryFrom(text), shown with show()):

TextResultWhy
"19.90"19.90the scale is the digits after the point
"19.9"19.9
"0019.90"19.90leading zeros dropped
"-0.00"0.00zero has no sign
"+5"5an explicit plus
"1.50e2"150scale 2 - 2
"12.5E-3"0.0125scale 1 + 3
"1e3"1000scale -3
"", " 1", "1 ", "1_000", ".5", "5.", "1e", "1e+", "nan", "inf", "0x10", "1,5", "1e2147483648"NumberParseErrorsection 4

Equality, order, hash:

OperationResult
1.0 == 1.00true
compare(1.10, 1.1)equal
compare(-0.5, 0.25)less
hash(19.90) == hash(19.9)true
show(19.90), show(19.9)19.90, 19.9

+, -, *:

ExpressionResultExpressionResult
19.90 + 0.120.001.5 * 2.003.000
1.5 + 23.519.99 * 359.97
0.1 + 0.20.3-0.5 * 0.5-0.25
1.00 - 1.5-0.500.00 * -30.00
0.10 - 0.100.005 - 7.25-2.25

/ (34 significant digits, half to even, preferred scale scale(a) - scale(b)):

ExpressionResult
1 / 30.3333333333333333333333333333333333
2 / 30.6666666666666666666666666666666667
1 / 70.1428571428571428571428571428571429
1 / 1.50.6666666666666666666666666666666667
100 / 333.33333333333333333333333333333333
0.1 / 30.03333333333333333333333333333333333
10 / 42.5
6.0 / 32.0
1.00 / 40.25
1.00000 / 40.25000
100 / 4.025
1 / 80.125
-7 / 2-3.5
19.90 / 0.539.8
0 / 4.000
1 / 0, 0 / 0.00panic division by zero in `/`

% (truncated, sign of the dividend, larger scale):

ExpressionResultExpressionResult
7 % 215.5 % 21.5
-7 % 2-1-5.50 % 0.4-0.30
7 % -216.0 % 30.0
1 % 311 % 0panic division by zero in `%`

rounded(scale:, rounding:): the columns are HalfEven, HalfUp, Down, Up, Floor, Ceiling.

Value, scaleHalfEvenHalfUpDownUpFloorCeiling
2.5, 0232323
3.5, 0443434
-2.5, 0-2-3-2-3-3-2
-3.5, 0-4-4-3-4-4-3
2.51, 0332323
2.0, 0222222
1.005, 21.001.011.001.011.001.01
0.125, 20.120.130.120.130.120.13
-0.125, 2-0.12-0.13-0.12-0.13-0.13-0.12
2.5, 32.5002.5002.5002.5002.5002.500
1250, -2120013001200130012001300

divided(by:, scale:, rounding:), same columns:

ExpressionHalfEvenHalfUpDownUpFloorCeiling
1.divided(by: 3, scale: 2)0.330.330.330.340.330.34
2.divided(by: 3, scale: 2)0.670.670.660.670.660.67
10.divided(by: 4, scale: 0)232323
1.divided(by: 8, scale: 2)0.120.130.120.130.120.13
-1.divided(by: 8, scale: 2)-0.12-0.13-0.12-0.13-0.13-0.12
1.divided(by: 3, scale: 0)000101

Conversions and literals:

ExpressionResult
Decimal.from(42), Decimal.from(-7)42, -7
Int64.tryFrom(19.00)19
Int64.tryFrom(19.5)NumberRangeError
Decimal.tryFrom(0.1) (a Float)0.1
Decimal.tryFrom(2.0)2.0
Decimal.tryFrom(0.1 + 0.2)0.30000000000000004
Decimal.tryFrom(Float.nan)NumberRangeError
Float.from(19.99) (a Decimal)19.99
const a: Decimal = 19.9019.90
const b: Decimal = 55
const c: Decimal = 1_000.501000.50
const d: Decimal = 0xFF255
const e: Decimal = -19.99-19.99
const f: Decimal = 0.1 ; f + 0.2 == 0.3true

13. Where this comes from#

SystemValueDivisionTaken or left
Java BigDecimalBigInteger and an int scaledivide throws on an endless expansion, MathContext otherwisethe model, and the preferred scale; no exception, no context
Python decimalcoefficient and exponent, context precision 28rounds to the contextthe rule for / with a fixed 34 and no global
C# decimal96-bit integer, scale 0 to 2828 digitsleft: a range limit loses what a database column holds
Go shopspringbig.Int and an int32 exponentglobal DivisionPrecision 16left: a mutable global
IEEE 754 Decimal12834 digits, exponent -6176 to 6111, with nan and infinitiesrounded to 34 digitsthe 34 digits and the half-even default; no nan, which a money type must not hold

14. Slices#

One pull request each, in this order, because each stands on the one before it. #34 stays the umbrella and closes with the last.

SliceIssueContent
1#332this record and the links to it
2#334runtime/decimal.c and its C tests; every native of section 10 declared in std/number and Ready in the manifest; Rounding; the VM reaches them. Programs build Decimal from text and compute
3#335literals and constants in the IR and both back ends (section 9): the checker's adaptation of integer and decimal literals, the constant evaluator, the static block; Decimal.zero and one
4#336the rest of the std surface (section 8), Encode/Decode and Describe with Exact (section 11), the conformance programs of section 12 in both back ends, the language page language/values-and-types/decimal.md from planned to stable

Not decided here and not needed for 1.0: Power and roots of a Decimal, a Decimal with a fixed width or a currency (a type Money over Decimal is a library), locale formatting (thousands separators, a decimal comma) and an inline form of small values (section 10). Each adds methods and changes no rule above.

Esc