Money in Rust: Making Illegal States Unrepresentable
Most financial bugs are not clever. They are a value added to another value that
happened to have the same machine representation and a completely different meaning.
Cents plus basis points. Euros plus dollars. A quantity plus a price. The compiler saw
two i64 values and did exactly what it was told.
Rust will not stop you from writing that code either, unless you spend a little type system on the problem. This is a tour of how much a little buys: newtypes, phantom currencies, checked arithmetic, explicit rounding and typestate. Everything here is public knowledge, and most of it costs zero bytes at runtime.
1. Floats are disqualified, and not for the reason you think
The canonical demo is the one everybody has seen:
assert_eq!(0.1_f64 + 0.2, 0.3); // fails: 0.30000000000000004
Binary floating point cannot represent 0.1 exactly, so it stores the nearest representable neighbour. One operation, one invisible sliver of error. Millions of operations across a ledger, and the slivers accumulate in a way that depends on the order the operations happened to run in.
But precision is the boring half of the argument. The real problem is that the bug is silent. Nothing panics, nothing logs, no alert fires. You find out weeks later when a reconciliation job reports that assets and liabilities differ by 0.03, and now you get to explain to somebody which of the two numbers is the true one. Neither is.
i64 holds about 92 quadrillion cents, which is enough for fiat;
i128 when you touch chains with 18 decimals.
2. The newtype pattern: a bare integer is a bug generator
An i64 called amount is a promise you make in a variable
name and break in a function signature. It will happily add itself to a timestamp, a
quantity, a fee in basis points, or an amount in a different currency. Wrap it:
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Cents(i64);
impl Cents {
pub const fn new(minor_units: i64) -> Self { Cents(minor_units) }
pub const fn as_minor_units(self) -> i64 { self.0 }
}
The field is private, so the only way in and out is through named constructors. What you derive matters, but what you refuse to derive matters more:
- No
Default. A default money value is a silent zero, and a silent zero is how a missing fee becomes a free trade. Make callers sayCents::ZEROif they mean it. - No
Dereftoi64. That would hand back every operation you just took away, which defeats the whole exercise. - No blanket arithmetic.
Addonly between twoCents. Multiplication by anotherCentsis meaningless: cents squared is not a unit. Multiplication by a scalar or a rate is fine, and it should have its own signature. - Ordering, yes. Comparing money to money is legitimate and you want it in the type.
3. Phantom types: currency at compile time, zero bytes at runtime
Cents still lets you add dollars to euros. Push the currency into the
type parameter and it stops:
use core::marker::PhantomData;
pub trait Currency {
const CODE: &'static str;
const EXPONENT: u32; // minor units per major unit, as a power of ten
}
pub struct USD;
impl Currency for USD { const CODE: &'static str = "USD"; const EXPONENT: u32 = 2; }
pub struct EUR;
impl Currency for EUR { const CODE: &'static str = "EUR"; const EXPONENT: u32 = 2; }
pub struct Money<C: Currency>(i64, PhantomData<C>);
One wrinkle worth knowing: derive on a generic struct adds the same bound
to every type parameter, so #[derive(Clone)] demands C: Clone
even though the currency tag is never stored. USD is a unit struct that
implements nothing, so the derives you actually need are written by hand and the phantom
bounds disappear:
impl<C: Currency> Clone for Money<C> { fn clone(&self) -> Self { *self } }
impl<C: Currency> Copy for Money<C> {}
// same story for Debug, PartialEq, Eq, PartialOrd and Ord
impl<C: Currency> core::ops::Add for Money<C> {
type Output = Money<C>;
fn add(self, rhs: Self) -> Self {
Money(self.0.checked_add(rhs.0).expect("money overflow"), PhantomData)
}
}
Now usd + eur is not a runtime check that somebody forgot to write. It
is a type mismatch, reported before the binary exists. And
PhantomData<C> is zero-sized, so Money<USD> has
exactly the same layout and the same eight bytes as the i64 inside it. You
paid nothing.
4. Conversion becomes explicit because it has to be
Once cross-currency addition cannot compile, the only way to combine currencies is a function that takes a rate. Give the rate a type that carries its own direction:
pub struct Rate<From, To> {
numerator: i64, // scaled by 10^SCALE
_dir: PhantomData<(From, To)>,
}
impl<F: Currency, T: Currency> Rate<F, T> {
pub fn convert(&self, amount: Money<F>) -> Money<T> { /* mul, then round */ }
pub fn invert(self) -> Rate<T, F> { /* explicit, and a new type */ }
}
A Rate<EUR, USD> can only consume Money<EUR>
and can only produce Money<USD>. Feeding it dollars is a compile
error. Using it upside down, the single most common FX bug in existence, is a compile
error. Inverting a rate is still possible, but you must say invert() out
loud, and the result is a different type, which means the reviewer sees the decision
instead of guessing at it.
5. Checked arithmetic, because release builds wrap
Rust panics on integer overflow in debug builds. In release builds, by default, it wraps. For money that is precisely the wrong behaviour: a balance that silently becomes a large negative number is worse than a crash, because a crash leaves a stack trace and wrapping leaves a plausible-looking balance.
impl<C: Currency> Money<C> {
pub fn checked_add(self, rhs: Self) -> Option<Self> {
self.0.checked_add(rhs.0).map(|v| Money(v, PhantomData))
}
pub fn checked_mul_scalar(self, k: i64) -> Option<Self> {
self.0.checked_mul(k).map(|v| Money(v, PhantomData))
}
}
Two things follow. First, turn on overflow-checks = true in your release
profile for anything that touches money; the cost is a branch you will never notice and
the benefit is that wrapping stops being reachable at all. Second, decide consciously
what the ergonomic + operator does. Panicking on overflow is a legitimate
policy for an internal ledger where overflow means the input validation upstream already
failed. It is a terrible policy inside a request handler. Pick one, write it down, and
keep the checked_* family available for the paths that must degrade
gracefully.
6. Rounding is a domain decision, not a default
Addition and subtraction stay inside the domain of minor units. Division and multiplication by a rate do not: they produce fractions that must be forced back onto the integer grid, and the way you force them is a business rule, not an implementation detail.
- Half-up is what people expect from school and what many consumer-facing price displays use.
- Half-even (banker's rounding) breaks ties toward the even neighbour so that repeated rounding does not drift upward. Standard in a lot of accounting and settlement work.
- Truncation toward zero is what
/gives you on Rust integers, and it is almost never what you meant.
The nastier version is allocation. Split 100 cents three ways and each share is 33 with a cent left over. Rounding each share independently loses money. Distribute the remainder instead:
pub fn allocate(total: i64, parts: usize) -> Vec<i64> {
let n = parts as i64;
let base = total.div_euclid(n);
let mut rem = total.rem_euclid(n);
(0..parts).map(|_| {
let extra = if rem > 0 { rem -= 1; 1 } else { 0 };
base + extra
}).collect()
}
// allocate(100, 3) == [34, 34, 32]
The invariant is short enough to tattoo on the module: the sum of the parts equals the whole, always. Same idea for weighted splits, pro-rata fee distribution and dividend payouts. Somebody gets the extra cent, and the rule for who gets it is a product decision you should make on purpose rather than inherit from whichever way the floor function happened to fall.
7. Typestate: lifecycle in the type, not in an if statement
The same phantom trick models workflow. Give a transaction a state parameter and let
each transition consume self:
pub struct Draft; pub struct Approved; pub struct Signed;
pub struct Transaction<S> { payload: Payload, _state: PhantomData<S> }
impl Transaction<Draft> {
pub fn approve(self, by: ApproverId) -> Transaction<Approved> { /* ... */ }
}
impl Transaction<Signed> {
pub fn broadcast(self) -> Result<TxId, BroadcastError> { /* ... */ }
}
broadcast() simply does not exist on a Transaction<Draft>.
There is no if !self.approved { return Err(...) } to forget, because the
method is not in scope. Because each transition takes self by value, the
old state is moved out and cannot be reused, which kills double-approval and
double-broadcast at the same time. The cost is that state becomes static, so anything
genuinely dynamic (a state machine driven by a database column) still needs a runtime
enum at the boundary. Use typestate where the flow is known at compile time and it will
delete a whole category of guard clauses.
8. Property tests for what types cannot say
Types encode shape. They do not encode arithmetic laws. That is what property-based
testing is for: state the invariant, let proptest or
quickcheck hunt for the counterexample.
proptest! {
#[test]
fn allocation_preserves_total(total in -1_000_000i64..1_000_000, n in 1usize..64) {
let parts = allocate(total, n);
prop_assert_eq!(parts.len(), n);
prop_assert_eq!(parts.iter().sum::<i64>(), total);
}
}
Other invariants worth pinning: add then subtract is the identity when nothing overflows, conversion followed by conversion back lands within one minor unit of the original, and ordering is consistent with the underlying integer. Shrinking is the part that earns its keep. When the property fails, the framework hands you the smallest input that breaks it, which is usually a one-line bug report you could not have written yourself.
9. The honest trade-offs
This design is not free, it is just cheap in the places people expect it to be expensive.
- Serialization gets fiddly.
serdederives onMoney<C>want bounds onCthat a unit struct does not satisfy, so you end up writing manualSerializeandDeserializeimpls or reaching for theboundattribute. - Heterogeneous collections need an escape hatch. A balance sheet
holding several currencies cannot be a
Vec<Money<C>>. You need an enum or a runtime-taggedAnyMoney { minor: i64, code: CurrencyCode }at the edges, and a conversion into the typed world just inside them. - Error messages grow. A mismatch three generic layers deep prints a paragraph, and new team members will need a hand reading it the first time.
- The guarantee stops at your process boundary. JSON does not have
types. A request body carrying
{"amount": 12.5, "currency": "usd"}is still a float and still a string, and the only thing standing between it and your ledger is a validating parse. Types protect the inside of the house; the front door still needs a lock.
And sometimes the right answer is smaller. If you handle one currency, do arithmetic
that is mostly additive, and need decimal semantics for reporting, a well-tested
Decimal crate plus one newtype gets you most of the safety for a fraction
of the ceremony. The generic machinery earns its place when you are multi-currency,
when conversions are frequent, or when the same numeric type flows through many modules
maintained by many people. That is exactly when a human reviewer stops catching unit
errors and the compiler needs to take over.
The checklist
- Never floats. Integer minor units,
i64for fiat andi128where decimals run deep. - Newtype everything, private field, named constructors, no
Default, noDeref. - Currency in the type parameter via
PhantomData, so mixing currencies is a compile error at zero runtime cost. - Conversions take a directional
Rateand inverting is an explicit call that changes the type. - Checked arithmetic by default, plus
overflow-checks = truein release, plus a written policy for what+does on overflow. - Rounding and allocation are named domain rules with the sum-of-parts-equals-whole invariant tested, not implied.
- Property tests for the laws, and a validating parse at every boundary where the outside world hands you a number.
Type-driven design does not make money code correct. It makes a specific set of wrong programs impossible to build, which is a smaller claim and a far more useful one. The bugs that survive are the ones worth your attention.