Learning Scala: How to Model Money in Scala Using Opaque Types

A customer buys three items at $19.99, the total comes back as 59.96999999999999, and somewhere downstream we must ensure that we don't end up with $59.97 on the invoice and $59.96 in the ledger. The instinct is to reach for more precision. But precision was the easy part. A drifting penny is a symptom. The disease is poor modeling. Money is not a number. Money is a number plus everything we forget to carry with it.

New to this series?

Catch up on earlier posts to follow along with the Functional Programming Isn’t Just for Academics series:

Each post in this series explores how teams use Scala to build applications that stay clean, testable, and easy to scale.

Precision Is the Easy Problem

The standard progression is well-worn. Double is wrong because binary floating point cannot represent $0.10. BigDecimal is better. Better still, for many systems, is to drop the fraction entirely and store money as a whole number of minor units: not $59.97 but 5997 cents. Integers add, subtract, and compare without surprises. Most mature payment code settles here, and settling here is correct.

But notice what that fix actually addresses: precision. It makes the arithmetic exact but says nothing about which money this is, and it quietly assumes something about the kind of money it is. Those two omissions are where bugs live and breed. They are harder bugs, because they do not announce themselves with a stray nine in the fourth decimal place. They announce themselves much later, in a different currency or a different country, as real money.

When Correct Arithmetic Produces Wrong Answers

Here is a trap cloaked in a perfectly reasonable-looking function signature:

Scala
def total(lines: List[Long]): Long = lines.sum

Long cents in, Long cents out. It compiles. It passes a unit test written by someone whose test data is all US dollars. And the first time a EUR line item and a USD line item land in the same list, it produces a number that is arithmetically valid and commercially meaningless. Adding 1000 cents of USD to 1000 cents of EUR yields 2000 cents of nothing meaningful. The number could not stop this, because the number does not know it is dollars.

How to Make the Compiler Catch Currency Errors

The fix is to make a money value carry its currency, and to make that currency something the compiler checks:

Scala
opaque type Money = (Long, Currency)   // minor units, currency
 
object Money:
 def apply(minorUnits: Long, currency: Currency): Money = (minorUnits, currency)
 extension (m: Money)
   def minorUnits: Long     = m._1
   def currency:   Currency = m._2
   def +(other: Money): Either[CurrencyMismatch, Money] =
     if m.currency == other.currency then
       Right(Money(m.minorUnits + other.minorUnits, m.currency))
     else
       Left(CurrencyMismatch(m.currency, other.currency))

Addition now returns Either. You cannot add two Money values and simply get a Money back, you get either a sum or a typed refusal. Cross-currency arithmetic is no longer a silent number. It is a case the caller has to handle, and the only honest way to handle it is to convert one value into the other's currency first, through an explicit function that carries an exchange rate and the time the rate was taken. A converted amount is a different fact than an original one.

One more thing worth noting: locale is not in this type at all, and that is deliberate. A Money value has a currency but not a locale. Locale is a property of rendering a value for a particular human, and of parsing a string a particular human typed. Both are boundary operations. Parsing especially can fail, and a localized-money parser should return Either or Option, never a bare number. 1.000,50 read with the wrong locale is off by a factor of a hundred thousand and will not look wrong.

When Integer Cents Aren't Enough

Storing money as integer minor units smuggled in an assumption: that the minor unit is the smallest amount of money the system will ever need to represent. For money that changes hands, that is true. Banks settle in whole cents; you cannot wire someone a third of a cent. But not every monetary value in a commerce system is money that changes hands.

Consider a B2B order: 100,000 fasteners at a negotiated price of $0.0034 each. The line total is $340.00, or 34000 cents, comfortably a whole number. But the unit price is $0.0034. That is 0.34 of a cent. It is not representable as integer cents at all, even with the Money type discussed above.

This is not an exotic edge case. Bulk industrial pricing lives below the cent routinely. So does anything metered: a service billed at three dollars per million calls is charging $0.000003 per call. The per-unit price is sub-cent by orders of magnitude, and a "money is integer cents" model cannot express it.

Amount vs. Price: Two Different Types for Two Different Things

Bolting decimal places onto the money type is the wrong fix if it conflates two genuinely different concepts. Consider the distinction:

  • An Amount is settled money: a quantity that has changed or will change hands. It is denominated in whole minor units, because that is what banks move. Integer cents is correct for an Amount.
  • A Price is a rate: money per unit of something. It is a ratio, not a settlement. It can be arbitrarily precise, because nobody settles a price; they settle the Amount that a Price and a quantity produce.
Scala
opaque type Amount = (Long, Currency)        // whole minor units — settled money
opaque type Price  = (BigDecimal, Currency)  // money per unit — arbitrary precision
 
object Price:
 extension (p: Price)
   def perUnit:  BigDecimal = p._1
   def currency: Currency   = p._2
 
def lineTotal(price: Price, quantity: Long): Amount =
 val raw: BigDecimal = price.perUnit * BigDecimal(quantity)
 Amount(
   raw.setScale(0, RoundingMode.HALF_EVEN).toLong,
   Price.currency
 )

The rounding happens exactly once, in lineTotal, when a Price and a quantity become an Amount. It does not happen when the price is stored, and it absolutely does not happen per unit. Rounding the unit price to cents first and then multiplying is the canonical bulk-pricing bug: $0.0034 rounds to $0.00, and 100,000 times $0.00 is zero. The buyer's $340 order becomes free, and the arithmetic was exact the whole way down.

Displaying a Price is its own small discipline. You do not render $0.0034 as "$0.00", you either render the precise figure, or you render it against a larger unit: "$3.40 per thousand," "$3.00 per million." And the moment you say "per thousand," you have stopped talking about money and started talking about units.

The Pattern Repeats: Quantities Have the Same Problem

If we step back from money, the same shape appears everywhere a commerce value looks like a number. Assume your customer orders 3,000 cans of soda. How many are in a pallet at the warehouse? Will you ship whole pallets, cases, and individual cans? What if you sell wire by the linear foot but stock it in two-meter spools? What will you send a customer who orders 400 feet, and who pays the return freight when they needed it to be contiguous?

A length should carry its unit, the way Money carries its currency:

Scala
enum LengthUnit:
 case Millimeter, Inch, Foot, Meter
 
opaque type Length = (BigDecimal, LengthUnit)

Now you have the framework to convert units and set maximum quantities. Go back to the soda order. A line on a purchase order says "Quantity: 3." Three what? The SKU is counted and sold by the can, but in the warehouse you may have pallets of 12-packs, 24-packs, or cases of four to six packs. A SKU has a default quantity of 1, but a quantity should carry its unit of measure, and converting between units of measure is not arithmetic. It depends on this SKU's pack structure, which is data, so the conversion is an operation that can fail.

The folks building the model are often thinking about the website and may dismiss this as a trivial UI concern: just make the user order whole numbers of SKUs and let them do the math. But cart calculus may not be aligned to shipping calculus. Don't lose anything from order capture through submission, acceptance, pick, pack, ship, and resolution.

Give Numbers Back Their Context

A number is a value with its context cut away. That is what makes it a number: it is pure, portable, and it composes under arithmetic. That is exactly what you want for most computing tasks. But digital commerce is one of many domains where the context matters and may change from one part of the process to another.

My high school chemistry teacher graded lab reports with one firm rule: "Digits without units are meaningless and therefore wrong." She would have fits grading most of our software. But the discipline she was describing is small, and boring, and it works. Give the value a type that carries what the number forgot: a currency, a unit, a precise amount or rate with a unit of measure. Then the operations that were silently wrong become code the compiler will not let you write.

This is Part 13 in an ongoing series. If you found this useful, Part 12 explores how vague data models, availability statuses, price fields, and fulfillment strings, break automated decision-making, and shows how Scala ADTs and pattern matching let you represent business states explicitly so software buyers can classify results and act on them reliably. Read "Modeling Business States for Automated Systems"

 
Next
Next

Staff Augmentation vs. Dedicated Team vs. Project-Based Outsourcing: Which Model Fits?