A quantity is an exact amount of a measure, and the measure it was given is the measure it keeps: three dozen stays three-of-dozen for the invoice line while comparing and converting exactly against anything else of its kind.
import world.*
import world.money.*
import world.quantity.*
import world.quantity.Measure.*
Measures and quantities
val stock = Dozen(3) + Each(5)
(stock.measure.symbol, stock.amount)
stock.in(Each)
Amounts are exact rationals, so repeated conversion never drifts:
Kilogram(BigDecimal("2.5")) - Gram(300) == Kilogram(BigDecimal("2.2"))
Each(5).in(Dozen).amount
Kinds keep unrelated measures apart at compile time:
Measure.Kilogram(1) + Measure.Litre(1) // does not compile
Measure.Kilogram(1).in(Measure.Litre) // does not compile
The non-metric set is exact by international definition, so nothing here is curated and nothing floats. The two gallons are deliberately two measures, because the difference is a real cross-market trap:
Pound(1).in(Gram).amount
GallonUS(1) < GallonImperial(1)
Your own packaging is data
A crate of 24 is a value, not a compiled type. Its positivity is decided while the build runs, so a product catalogue carries no Either at its declarations:
val crate = Measure[Count]("crate24", 24)
crate(2) =~ Dozen(4)
=~ compares magnitude across measures; == is structural and preserves presentation:
Dozen(1) =~ Each(12)
Dozen(1) == Each(12)
A consumer kind is one line, and composes with everything:
trait Sacks extends Kind
Measure[Sacks]("sack50", 50)(2).base
Rounding, again at a boundary
(Kilogram(1) / 3).map(_.rounded(3, Rounding.Down))
Kilogram(1) / 0
Prices
A price is money per measure. Totalling is a rounding boundary and names its mode:
val flour = Currency.KES(250).per(Kilogram)
flour.total(Gram(1250), Rounding.HalfUp).amount
flour.in(Gram, 4, Rounding.HalfUp).amount.amount
The other two directions are named operations too. A pump sells an amount of money's worth of fuel, rounding down because it must never dispense more than was paid for:
Currency.KES(180).per(Litre).quantity(Currency.KES(2000), 3, Rounding.Down)
Weighted-average cost is total money over total quantity:
Price.of(Currency.KES(BigDecimal("7200.00")), Each(24), Rounding.HalfUp).map(_.amount.amount)
Price.of(Currency.KES(100), Each(0), Rounding.HalfUp)
Billable time is a quantity
Time is a quantity kind, so labour and hire price through the same algebra - there is no second mechanism to learn or to keep consistent:
Currency.KES(1500).per(Hour).total(Minute(210), Rounding.HalfUp).amount
Measure.Day(1).in(Measure.Hour)
Durations come from the civil and absolute clocks alike, and feed straight back in:
DateTime.parse("2026-07-24T17:00:00").toOption.get
.until(DateTime.parse("2026-07-27T09:00:00").toOption.get)
.in(Hour).amount
Currency.KES(1500).per(Hour)
.total(Instant.seconds(0).until(Instant.seconds(5400)), Rounding.HalfUp).amount
Adding a duration to a civil date-time carries the day, and refuses a sub-second remainder rather than rounding it silently - that decision is yours to make before the boundary:
DateTime.parse("2026-07-26T23:30:00").toOption.get.plus(Hour(1)).map(_.value)
DateTime.parse("2026-07-26T14:30:00").toOption.get.plus(Second(Ratio(1, 2))).isLeft
Converting between kinds
Nothing converts across kinds without a declared fact, which is how dimensional safety survives contact with the real world: "5 kg is 3 pieces" is the product's own truth, captured once and applied explicitly.
val piecesPerMass = Conversion.of(Kilogram(5), Each(3)).toOption.get
Kilogram(BigDecimal("7.5")).via(piecesPerMass) =~ Each(Ratio(9, 2))
piecesPerMass.inverse.map(inv => Each(3).via(inv) =~ Kilogram(5))
Areas and volumes
The two products commerce actually cuts and ships - area from lengths, volume from an area and a length - present in the derived kind's SI measure:
Metre(3) * Metre(2)
(SquareMetre(6) * Metre(2)) =~ Litre(12000)
Hectare(1) =~ SquareMetre(10000)
Block tariffs
Consumption fills each block in turn at that block's own unit price - the utility bill, the stepped hire rate. The table is your data; world supplies the arithmetic:
trait Energy extends Kind
val KilowattHour: Measure[Energy] = Measure[Energy]("kWh", 1)
val power = Blocks.of(
KilowattHour,
Blocks.upTo(Ratio(50), Currency.KES(12)),
Blocks.open(Currency.KES(20))).toOption.get
power.charges(KilowattHour(120), Rounding.HalfUp).map(_.map(_.amount))
power.total(KilowattHour(120), Rounding.HalfUp).map(_.amount)
charges rounds each block line, as a bill prints them. total sums exactly and rounds once, so no per-block bias accumulates - the two can differ by rounding units, and that is deliberate.
A wholly bounded table refuses a quantity past its cap rather than extrapolating:
val hire = Blocks.of(
Hour,
Blocks.upTo(Ratio(2), Currency.KES(100)),
Blocks.upTo(Ratio(8), Currency.KES(50))).toOption.get
hire.total(Hour(8), Rounding.HalfUp).map(_.amount)
hire.total(Hour(9), Rounding.HalfUp)
Rate cards
A select-one card prices the whole quantity by its containing row, flat or per unit. A published card's boundary arithmetic is preserved as published - a larger consignment can cost less across a break, and that is the genre's semantics, not a defect to repair:
val freight = Breaks.of(
Kilogram,
Breaks.upTo(Ratio(5), Breaks.Charge.Flat(Currency.KES(500))),
Breaks.upTo(Ratio(30), Breaks.Charge.PerUnit(Currency.KES(90)))).toOption.get
freight.charge(Kilogram(5), Rounding.HalfUp).map(_.amount)
freight.charge(Kilogram(BigDecimal("5.5")), Rounding.HalfUp).map(_.amount)