Money in Rust: Making Illegal States Unrepresentable

Maciej Lewandowski · July 2026 · Engineering notes

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.

Money is not a real number. It is a count of minor units: 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. 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:

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.

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.

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

  1. Never floats. Integer minor units, i64 for fiat and i128 where decimals run deep.
  2. Newtype everything, private field, named constructors, no Default, no Deref.
  3. Currency in the type parameter via PhantomData, so mixing currencies is a compile error at zero runtime cost.
  4. Conversions take a directional Rate and inverting is an explicit call that changes the type.
  5. Checked arithmetic by default, plus overflow-checks = true in release, plus a written policy for what + does on overflow.
  6. Rounding and allocation are named domain rules with the sum-of-parts-equals-whole invariant tested, not implied.
  7. 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.