Money in Rust: Making Illegal States Unrepresentable

Maciej Lewandowski · · Engineering notes

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.

Treat money as a count of minor units rather than a real number: cents, satoshis, wei-scaled units, whatever the domain calls them. Counts are integers. 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: serde derives on Money<C> want bounds on C that a unit struct does not satisfy, so you end up writing manual Serialize and Deserialize impls or reaching for the bound attribute.
  • 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-tagged AnyMoney { 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.