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 53 bits wide, from -9007199254740991 to 9007199254740991, and leaving that range raises an error rather than producing a number nobody asked for. The width is chosen by the weakest of the three runtimes, not the strongest: JavaScript has no integers at all, so an Int in the browser is a double, and past 2^53 it silently stops being able to tell 9007199254740993 from 9007199254740992. Before the bound, the same expression wrapped on the server, crashed on the phone, and returned a wrong answer in the browser — and only the browser said nothing. For values that genuinely need 64 bits, such as ids minted by someone else’s system, carry them as String.

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. 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 — but the line falls between quantities, not between numbers on a page. A quantity has a type already: with n : Int, n + 1.5 is a type error, and Decimal.fromInt n is how you cross deliberately. A literal has no type yet, so the context gives it one: 1 + 1.5 is 2.5, and price : Decimal / price = 1 needs no ceremony. The adaptation only ever widens — count : Int = 1.5 stays an error, because a silently truncated fraction is exactly the unit bug the rule exists to prevent.

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 split without losing anything (q * b + r == a, guaranteed):
Decimal.withRemainder 2 (100.00 / 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.sum amounts

List.sum is List number -> number: one name that adds a list of Int or a list of Decimal, and even an empty list of either, because the compiler tells the runtime which zero this call wanted. 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.

An angle is not a number

Banning floats leaves an obvious hole, and it is the first one a game hits: how do you turn something? Sine and cosine are the arch-typical float functions, and every language hands them to you as Double -> Double.

Mar’s answer has two halves, and the first one is not about numbers at all.

The angle gets a type. Not an Int of degrees, not an Int of tenths of a degree — an Angle, and the constructor is the only place a bare number appears:

Math.degrees 45          -- whole degrees
Math.deciDegrees 450     -- tenths of a degree: the same angle, said finer
Math.turns 64            -- 256 to a turn, the unit a game usually counts headings in

This is the third time Mar has made this move. Time.every does not take 1000, it takes Time.seconds 1. UI.width does not take 6, it takes UI.chars 6. The reason was the same all three times: a number alone does not say what it is, and the failure mode of getting it wrong is not a crash but a game that turns ten times too slowly while every test stays green. A unit that lives in a comment is a unit that goes stale.

Any Int is a valid argument, because construction wraps: Math.degrees 360 is Math.degrees 0, and Math.degrees -90 is Math.degrees 270. The arithmetic ships with the type, because an angle you cannot add is not a safer number — it is one every caller unwraps, adds, and rewraps, restoring the hazard with more ceremony:

heading = Math.add model.heading (Math.deciDegrees (turn * 15))
away    = Math.opposite model.heading

The answers are integers, and they come from a table. Trigonometry returns thousandths, the same fixed-point convention the rest of the API already uses for percentages:

Math.sin : Angle -> Int          -- -1000 .. 1000
Math.cos : Angle -> Int
Math.atan2 : Int -> Int -> Angle -- the angle of a vector; y first
Math.isqrt : Int -> Int          -- whole part of a square root

Math.sin (Math.degrees 30) is 500. Not approximately 500: 500, on the server, in the browser, and on the phone, because all three read one quarter-wave table that is computed once when Mar itself is built and shipped verbatim into each runtime. Calling each platform’s sin would have put three different implementations between a program and its answer, which is the determinism argument from the top of this chapter arriving in a new costume.

Every function here is total. Math.atan2 0 0 is 0°, Math.isqrt -5 is 0. There is nothing to guard.

The seam runs all the way to the screen: Canvas.Rotate takes an Angle as well, so a heading computed with Math goes straight to the canvas and there is no bare Int left for the wrong unit to hide in.

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.

Numbers that arrive from outside

The guarantees above hold inside a Mar program, and one boundary is where they would otherwise be given away: a JSON document written by something else. Mar’s own values travel as strings under a marker, so no parser gets a chance at them. A foreign document has no marker, and a bare 1e30 is exactly what a JSON parser turns into a float before any Mar code runs.

So a number arriving from outside is read as text, not through the host’s parser, and it becomes one of two things:

  • an Int, if it is whole and inside 53 bits;
  • an exact Decimal, if it fits 34 significant digits.

Anything else is refused, by name, in the same sentence on all three runtimes. There is no third case, because there is no third number type to be the third case.

One consequence is worth stating on its own: notation is not information. These two documents carry the same value and now decode the same way.

{ "total": 1000000000000000000000000000000 }
{ "total": 1e30 }

They did not always. Exponent form is what every JSON serializer prints for a large number, and reading the digits rather than the parser’s Double is what makes the two agree.

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.