Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Integers and Decimals

Mar has two number types, and neither of them is a float. Int is a whole number. Decimal is an exact base-10 number: 19.99 is really 19.99, not the closest 64-bit approximation. There is no Float and no Double anywhere in the language. Of all Mar’s decisions this is the one that raises eyebrows first, so it deserves a chapter: what goes wrong with floating point, how Mar’s two types divide the work, and why division has an unusual type.

The case against floating point

Floating-point numbers are a brilliant engineering compromise from the era of scarce memory: represent a huge range of magnitudes in 64 bits by storing a mantissa and an exponent, accepting that almost nothing is exact. Three consequences follow, and every working programmer has been bitten by at least one:

They lie about simple arithmetic. In float-land, 0.1 + 0.2 == 0.3 is false. Not a bug; the definition. 0.1 has no exact binary representation, so the language stores something near it, and errors accumulate with every operation. In Mar:

0.1 + 0.2 == 0.3        →  True

Decimal literals are stored as exact base-10 values (a coefficient and a scale, like 1999 at scale 2 for 19.99), so arithmetic behaves the way you learned it in school.

They are poison for money. The classic production incident: prices as floats, a few million operations, cents drifting. Every serious finance codebase ends up banning floats and using integer cents or a decimal library. Mar makes the ban structural and ships the decimal library as the built-in type.

They break determinism across platforms. The same float expression can produce subtly different results on different hardware, math libraries, or optimization levels. Mar programs run on three runtimes (JavaScript, Swift, Go) that must agree exactly: a game replay, a rules engine evaluated on both server and client, a test recorded on one machine and run on another. Int and Decimal arithmetic are bit-for-bit identical everywhere, forever. With floats, “the same program” quietly becomes three programs.

Two types, two jobs

Int is for counting and for game math. Ticks, indices, pixel coordinates, scores, durations in milliseconds. Mar’s games run their physics in “px times 16”: one pixel is 16 units, giving 1/16-pixel precision with pure integers. A speed of 2.5 pixels per tick is stored as 40. Angles are degrees as integers; Canvas.Rotate takes them directly. Entire pseudo-3D racers, raycasting engines, and chip-tune scores are written this way, for the same reason games were written this way for decades: exactness and speed.

Decimal is for measuring. Money above all, but also quantities, rates, anything a human writes with a decimal point. 1.50 remembers it was written with two places (it prints back as 1.50, not 1.5), while == compares numerically, so 1.50 == 1.5 is True. Addition, subtraction, and multiplication are exact and closed: Decimal + Decimal is a Decimal with no rounding, ever.

The two do not mix silently. 1 + 1.5 is a type error; convert deliberately with Decimal.fromInt. Mixing is where unit bugs live, so the compiler makes you say what you mean.

Division names its precision

Division is where every numeric design shows its cards. 1 / 3 has no exact decimal answer, so something has to give: floats give you an approximation silently, and most decimal libraries pick a rounding rule for you. Mar refuses to round behind your back, and it splits division in two:

// is integer division. It truncates toward zero, and the doubled slash wears the loss in its spelling: 7 // 2 is 3, -7 // 2 is -3, and (so the same program cannot crash on one runtime and not another) n // 0 is 0. When order matters, multiply before dividing: a * 75 // 1000 keeps precision that a // 1000 * 75 throws away.

/ is Decimal division, and it returns a question, not a number. Its result is a Decimal.Division: the exact quotient held in suspense. You cannot print it, store it, or add to it. Exactly three functions resolve it, and each one makes you write down the precision:

-- Name the rounding and the scale:
1.0 / 3.0 |> Decimal.rounded Decimal.HalfEven 4     -- 0.3333

-- Or take the exact answer when one exists:
Decimal.exact (1.0 / 4.0)                            -- Just 0.25
Decimal.exact (1.0 / 3.0)                            -- Nothing

-- Or split without losing anything (q * b + r == a, guaranteed):
Decimal.withRemainder 2 (100.00 / Decimal.fromInt 3)
-- { quotient = 33.33, remainder = 0.01 }

That last one is the correct answer to a very old interview question: split $100 across three people and account for every cent. The rounding modes are the standard six (Decimal.HalfEven is banker’s rounding, plus HalfUp, Down, Up, Floor, Ceiling), and they appear only at the resolvers. There is no configuration flag, no ambient precision, no rounding you did not write at the call site. The lineage here is COBOL’s ROUNDED clause and Ada’s fixed-point types, the tools banks actually trusted, restated as a pipeline.

Money in practice looks like this:

formatMoney : Decimal -> String
formatMoney amount =
    "$" ++ Decimal.toString (Decimal.toScale Decimal.HalfEven 2 amount)

total : List Decimal -> Decimal
total amounts =
    List.foldl (\acc a -> acc + a) Decimal.zero amounts

The sum needs no rounding because + is exact; only display picks a scale. On the database side, Entity.decimal 2 declares a money column: SQLite stores the integer coefficient (literally cents), reads come back as Decimal, and a write with more than two places aborts instead of rounding silently.

What you gain back

  • Arithmetic you can reason about with grade-school rules. a + b - b == a, always, in both types.
  • Equality that means equality. No epsilon comparisons, no “close enough” helpers.
  • One behavior on web, iOS, and server. The shared rules engine in Mar’s card game example is replayed independently by the Go backend and the JS client, and must land on identical states. Exact numbers make that a non-event.
  • Serialization without surprises. Decimals travel as strings on the wire, so no JSON parser anywhere gets a chance to turn 0.1 into 0.30000000000000004 downstream.

The honest costs

Some domains genuinely want approximate math with large dynamic range: scientific computing, signal processing, 3D transforms with arbitrary rotation. Mar is a poor fit for those today, and pretending otherwise would be silly. Decimal is capped at 34 significant digits, and a computation that overflows it errors rather than losing digits quietly. And the division pipeline costs a few more keystrokes than / in other languages; that is the price of never being surprised by a rounding you did not choose.

The design instinct to take away

Mar consistently prefers removing a footgun over documenting it. Null went; exceptions went; mutation went; floats went; silent rounding went. Each removal traded a familiar convenience for a guarantee the compiler can enforce. If that trade offends you, Mar will keep offending you. If it appeals, you now have the complete mental toolkit of Part I, and it is time to build something: a user interface, out of a loop, a value, and two pure functions.