decimal-scaled
A Rust library providing const-generic base-10 fixed-point decimal types with deterministic, bit-exact arithmetic.
Installation
Add to your Cargo.toml:
[]
= "0.1"
For no_std targets (disables std and serde):
[]
= { = "0.1", = false }
To enable serde without std:
[]
= { = "0.1", = false, = ["serde", "alloc"] }
Quick start
use I128s12;
// Construct from raw integer storage (value × 10^12)
let a = from_bits; // exactly 1.1
let b = from_bits; // exactly 2.2
// Constants
let zero = ZERO;
let one = ONE;
// Raw storage
assert_eq!;
assert_eq!;
Why another numeric type?
Every numeric type makes a choice about which numbers it can represent exactly. There is no universal answer - the right choice depends on where the numbers come from.
The binary fraction problem
Standard floating-point types (f32, f64, f128) store values as:
value = mantissa × 2^exponent
This means the fractional part of any stored number must be expressible as a sum of negative powers of two: ½, ¼, ⅛, 1/16, …
The number 1.1 cannot be expressed this way. In binary it is:
1.0001100110011001100110011001100110011001100110011... (repeating forever)
A 64-bit float truncates this at 52 mantissa bits. The value actually stored is:
1.100000000000000088817841970012523233890533447265625
This is not a bug - it is an unavoidable consequence of the representation. The same applies to 0.1, 0.2, 0.3, and most everyday decimal fractions. This is why 0.1 + 0.2 == 0.3 is false in every binary floating-point system.
The binary fixed-point alternative
The fixed crate (I64F64, I32F32, etc.) uses binary fixed-point: a fixed number of bits for the integer part and a fixed number of bits for the fractional part. A value is stored as:
value = raw_integer × 2^(-FRAC_BITS)
This eliminates the rounding from exponent adjustments, but the representable fractions are still powers of two. I64F64 cannot represent 0.1 exactly either. It excels at signal processing, physics simulations, and anywhere numbers arrive as binary data or are generated by mathematical operations.
Base-10 fixed-point: filling the gap
decimal-scaled uses base-10 fixed-point:
value = raw_integer × 10^(-SCALE)
With SCALE = 12, the number 1.1 is stored as the integer 1_100_000_000_000. It is exact. Every number a human can write with up to SCALE decimal digits is represented exactly. The tradeoff is that numbers like 1/3 or π still cannot be represented exactly - no finite representation can hold every number. The question is always which numbers you need to be exact.
Choosing the right number space
All numeric types have a finite number space. The choice is which region of the real line to cover densely and which values to round.
| System | Decimal places | Exact for | Rounds | Best suited for |
|---|---|---|---|---|
f64 |
dynamic (binary exponent, not decimal) | powers-of-2 fractions | decimal fractions like 0.1 | scientific computation, computer-generated values |
f128 |
dynamic (binary exponent, not decimal) | powers-of-2 fractions (more precision) | decimal fractions | high-precision scientific work |
fixed::I64F64 |
fixed (64 binary fractional bits, not decimal) | binary fixed fractions | decimal fractions | digital signal processing, physics, binary data |
rust_decimal |
variable per value (0–28, stored alongside each number) | decimal fractions up to 28 digits | repeating decimals | finance, variable scale |
bigdecimal |
variable per value (unbounded, heap-allocated) | any terminating decimal | repeating decimals | arbitrary-precision decimal work |
I128<S> (this crate) |
fixed to S at compile time |
decimal fractions up to S digits |
repeating decimals | finance, computer-aided design, human-entered values |
Use decimal-scaled when:
- Values are entered by humans as decimal strings (prices, measurements, quantities)
- You need deterministic, platform-identical results across every machine
- The scale is known at compile time and you want zero-cost const-generic specialisation
- You need
no_stdcompatibility - You want a single canonical representation per value (no normalisation step)
Use f64 or f128 when:
- Values come from sensors, physics engines, or mathematical operations
- The number space is continuous and decimal fractions are not special
- You need the dynamic range of IEEE 754 binary floating-point (from ~10⁻³⁰⁸ to ~10³⁰⁸)
Use fixed when:
- Values are in a known integer-and-fraction format from binary protocols
- You are doing digital signal processing or embedded arithmetic where binary fractions are natural
- You need the best throughput on platforms without hardware decimal support
Use rust_decimal when:
- Scale varies between values (e.g. mixing 0.1 and 0.001 in the same collection)
- You need up to 28 significant decimal digits
- You do not need
no_stdand can afford heap-allocated metadata
Use bigdecimal when:
- Precision requirements are unbounded or unknown at compile time
- Throughput is not a concern
What decimal-scaled provides
I128<const SCALE: u32> is a #[repr(transparent)] newtype around i128. The const generic SCALE is the base-10 exponent baked into the type at compile time. There is exactly one representation per value: no normalisation, no variable scale, no heap allocation.
stored = logical_value × 10^SCALE
With SCALE = 12, the value 1.5 is stored as 1_500_000_000_000i128.
Properties
- Deterministic - arithmetic is pure integer; identical bit-pattern outputs on every platform.
- Canonical - one scale means one representation per value.
Hash,Eq, andOrdare derived directly fromi128. Two values that are equal always hash identically, with no normalisation step. no_stdcompatible - compiles withno_std + allocwhen default features are disabled.num-traitscompatible - implementsZero,One,Num,Bounded,Signed,FromPrimitive,ToPrimitive, and theChecked*family.serdesupport - canonical-string serialize/deserialize behind theserdefeature (on by default).- Const-generic scale - future scale variants (
I128<6>,I128<18>) are free type aliases, not separate implementations.
Numeric comparison table
| Type | Storage | Base | 0.1 exact |
1.1 exact |
Range | no_std |
|---|---|---|---|---|---|---|
f32 |
32-bit IEEE 754 (binary floating-point standard) | 2 | No | No | ~±3.4 × 10³⁸ | Yes |
f64 |
64-bit IEEE 754 (binary floating-point standard) | 2 | No | No | ~±1.8 × 10³⁰⁸ | Yes |
f128 |
128-bit IEEE 754 (binary floating-point standard) | 2 | No | No | ~±1.2 × 10⁴⁹³² | Partial |
fixed::I64F64 |
128-bit binary fixed | 2 | No | No | ~±9.2 × 10¹⁸ | Yes |
fixed::I32F32 |
64-bit binary fixed | 2 | No | No | ~±2.1 × 10⁹ | Yes |
rust_decimal |
96-bit + scale byte | 10 | Yes | Yes | ±7.9 × 10²⁸ | No |
bigdecimal |
heap-allocated | 10 | Yes | Yes | Unbounded | No |
I128<12> or I128s12 (this) |
128-bit integer | 10 | Yes | Yes | ~±1.7 × 10²⁶ | Yes |
I128<6> or I128s6 (this) |
128-bit integer | 10 | Yes | Yes | ~±1.7 × 10³² | Yes |
Hash and equality contracts
A well-behaved numeric type must satisfy: if a == b then hash(a) == hash(b). The way different types handle this for values like 1.1 and 1.10 varies significantly.
| Type | 1.10 == 1.1? |
hash(1.10) == hash(1.1)? |
Hash implemented? |
How |
|---|---|---|---|---|
f32 / f64 |
Yes (same bit pattern) | N/A | No - Not-a-Number breaks the contract | - |
f128 |
Yes (same bit pattern) | N/A | No | - |
fixed::I64F64 |
Yes (same binary approximation) | Yes | Yes | structural (one representation) |
rust_decimal |
Yes | Yes | Yes | normalises trailing zeros at comparison and hash time |
bigdecimal |
Yes | Yes | Yes | normalises at comparison and hash time |
I128<S> (this) |
Yes | Yes | Yes | structural - scale is fixed, one bit pattern per value |
f32, f64, and f128 do not implement Hash in the Rust standard library because Not-a-Number values are not equal to themselves (NaN != NaN) while a structural hash would make all such values collide - the contract cannot be satisfied without special-casing.
For rust_decimal and bigdecimal, the normalisation is correct but carries a runtime cost on every comparison and hash call, and it means the stored representation is not canonical - you cannot memcmp two values.
I128<S> derives Hash, Eq, and Ord directly from i128. Because the scale is fixed at compile time there is exactly one i128 value per logical number. 1.10 and 1.1 parsed via FromStr both produce I128s12(1_100_000_000_000) - the same bit pattern - so equality and hashing are a single integer comparison with no runtime normalisation.
Key differences from fixed
The fixed crate's I64F64 has 64 bits of integer and 64 bits of binary fraction. Its least significant bit is 2⁻⁶⁴ ≈ 5.4 × 10⁻²⁰, and its maximum value is 2⁶³ - 1 ≈ 9.2 × 10¹⁸.
I128<20> has a least significant decimal digit of 10⁻²⁰ and a maximum value of i128::MAX / 10²⁰ ≈ 1.7 × 10¹⁸ model units. The two types offer comparable precision and range in this configuration, but with opposite trade-offs: I64F64 represents its fractional part in binary (exact for powers of two, rounded for decimal fractions), while I128<20> represents it in decimal (exact for decimal fractions, rounded for fractions like 1/3).
For human-scale decimal values I128 gives decimal-exact results with no rounding on input or output. For values derived from binary arithmetic or mathematical operations, I64F64 avoids the binary-to-decimal rounding boundary entirely.
Scale aliases
| Alias | SCALE |
1 least significant decimal digit | Approximate range |
|---|---|---|---|
I128s0 |
0 | 1 | ±1.7 × 10³⁸ |
I128s2 |
2 | 0.01 (cents) | ±1.7 × 10³⁶ |
I128s6 |
6 | 10⁻⁶ (µ) | ±1.7 × 10³² |
I128s12 |
12 | 10⁻¹² (p) | ±1.7 × 10²⁶ |
I128s18 |
18 | 10⁻¹⁸ (a) | ±1.7 × 10²⁰ |
I128s38 |
38 | 10⁻³⁸ | ±1.7 |
Aliases I128s0 through I128s38 are all provided. SCALE = 39 would overflow i128.
Features
| Feature | Default | Description |
|---|---|---|
std |
yes | Enables FromStr, Display, logarithm/exponential, trigonometry |
alloc |
yes | Enables string formatting on no_std |
serde |
yes | Enables Serialize/Deserialize via serde_helpers |
License
Licensed under either of:
- MIT license (LICENSE-MIT)
- Apache License, Version 2.0 (LICENSE-APACHE)
at your option.
Copyright 2026 John Moxley.
Third-party code attributions are listed in LICENSE-THIRD-PARTY.