Money in Rust: Making Illegal States Unrepresentable
The financial bugs I have debugged were rarely clever. In almost every case someone
added a value to another value that shared a machine representation and carried a
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 fail money, and precision is the smaller problem
You have seen the canonical demo:
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.
Precision is the boring half of the argument. The worse half is that the bug stays 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, knowing that 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. Nothing stops you from adding it 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. The derives you refuse matter more than the ones you write.
Skip Default: a default money value is a silent zero, and with a silent
zero in the type a missing fee ships as a free trade. Make callers write
Cents::ZERO if they mean it. Skip Deref to i64
too, because that would hand back every operation you took away and defeat the whole
exercise. For arithmetic, implement Add only between two
Cents; multiplying Cents by Cents gives you
cents squared, which is not a unit, while multiplying by a scalar or a rate makes
sense and deserves its own signature. Ordering you do want in the type, since
comparing money to money is legitimate.
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 struct never stores the currency tag. USD is a unit struct
that implements nothing, so you write the handful of impls you need 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 the compiler rejects usd + eur as a type mismatch before the binary
exists, where before you depended on a runtime check somebody remembered to write. And
PhantomData<C> is zero-sized, so Money<USD> keeps
the same layout and the same eight bytes as the i64 inside it. The
currency tag costs you nothing at runtime.
4. Conversions go through a rate that knows its direction
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>. Feed it dollars and the build
fails, and the same happens when you apply it upside down, the most common FX bug I
have met in the wild. You can still invert a rate, 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 the wrong behaviour: a balance that wraps to 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 not notice and
the benefit is that wrapping stops being reachable at all. Second, decide up front
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
Addition and subtraction stay inside the domain of minor units. Division and multiplication by a rate do not: they produce fractions that you have to force back onto the integer grid, and how you force them is a business rule the product side should choose with you.
- 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 rarely 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 fits in one line: the sum of the parts equals the whole. The same idea covers 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: the lifecycle lives in the type
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() does not exist on a Transaction<Draft>.
There is no if !self.approved { return Err(...) } to forget, because the
method is not in scope. And because each transition takes self by value,
the compiler moves the old state out so nobody can reuse it, which kills
double-approval and double-broadcast at the same time. The cost is that state becomes
static, so anything dynamic at heart (a state machine driven by a database column)
still needs a runtime enum at the boundary. Use typestate where you know the flow at
compile time and it will delete a whole category of guard clauses.
8. Property tests for what types cannot say
Types encode shape, and arithmetic laws sit outside their reach. Property-based
testing covers the laws: you state the invariant, then proptest or
quickcheck hunts 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
The design carries real costs. They land in different places than the runtime overhead people expect.
- 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>>, so you keep an enum or a runtime-taggedAnyMoney { minor: i64, code: CurrencyCode }at the edges, with a conversion into the typed world right 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 the validating parse you write at the door.
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. At that scale a human reviewer stops catching unit errors
and the compiler has to take over.
What I carry between projects
The setup I reuse: integer minor units instead of floats, i64 for fiat
and i128 where decimals run deep, wrapped in a newtype with a private
field, named constructors, and neither Default nor Deref.
Currency lives in a type parameter via PhantomData, so mixing currencies
fails to compile and costs zero bytes at runtime, and every conversion goes through a
directional Rate whose inversion is an explicit call that changes the
type.
On the arithmetic side I default to the checked_* family, set
overflow-checks = true in the release profile, and write down what
+ does on overflow. Rounding and allocation get named domain rules, with
the sum-of-parts-equals-whole invariant under a property test rather than implied, and
a validating parse guards every boundary where the outside world hands the ledger a
number.
Type-driven design buys something narrower than correctness: a specific set of wrong programs that no longer compile. I take that trade every time I work on money code, because the bugs that survive it are the ones that deserve a human's attention.