BigDecimal

Exact decimal numbers: arithmetic that keeps every digit without losing precision, and rounding only when you ask for it.

A Float cannot hold 0.1. What it holds is 0.1000000000000000055511151231257827, which is close enough until you add three of them and get 0.30000000000000004. The addition is not at fault; base two has no exact 0.1 to add. A BigDecimal works in base ten, so it does:

BigDecimal.fromString "0.1"
    |> Maybe.map (\d -> BigDecimal.add d d |> BigDecimal.add d)
    |> Maybe.map BigDecimal.toString
--> Just "0.3"

A value is a BigInt and a power of ten: unscaled * 10^-scale. There is no fixed precision anywhere, so add, subBy and mul never round. They widen. The only operation that can fail to terminate is division, and division and roundTo are the only operations that round a value. toStringWithPlacesUsing takes a Rounding too, but only to write a value down; it changes nothing.

One value, one representation

1.50 and 1.5 are the same number, and in this module they are also the same value. Every BigDecimal goes through a constructor that strips trailing zeroes, so == is numeric equality.

BigDecimal.fromString "1.50" == BigDecimal.fromString "1.5"
--> True

This is the opposite of java.math.BigDecimal, where the two are equal by compareTo but distinct by equals. We differ because Gren's == is structural and cannot be overridden. A type whose == disagrees with its compare is a trap in the language's most-used operator, and no amount of documentation removes it.

The cost is that the scale no longer records significance. A BigDecimal does not remember that a price was quoted to the cent. That is a formatting question, and you ask it at the edge:

BigDecimal.toStringWithPlaces 2 price --> "1.50"
type BigDecimal

A number of the form unscaled * 10^-scale.

The unscaled value is a BigInt and carries the sign, so there is no size limit on the digits. The scale is an ordinary Int and may be negative: 100 is stored as 1 with a scale of -2, because that is what stripping trailing zeroes leaves.

Every value is built through a constructor that does that stripping, which is what makes == numeric equality. Zero is unscaled = 0, scale = 0 and nothing else, so x |> subBy x == BigDecimal.zero is a reliable test.

Making them

zero : BigDecimal

Zero.

There is only one zero: a zero unscaled value is always given a scale of zero, so 0.00 and 0 and 0e-40 are all this value.

It is also the divisor that makes every division in this module return Nothing.

one : BigDecimal

One.

The step, and the numerator of a reciprocal: one |> divByTo 20 HalfEven x.

fromInt : Int -> BigDecimal

An ordinary Int, which is a whole number and so has a scale of zero or less. Above 2^53 an Int is already inexact, so this converts the double you passed, not the number you meant.

toInt : BigDecimal -> Maybe Int

Back to an Int, when it is a whole number and fits in one exactly.

There are two ways to get Nothing, and both are refusals rather than roundings: there is a fractional part, or the whole part is past Math.maxSafeInteger. Apply roundTo 0 first if you meant to discard the fraction.

BigDecimal.fromString "2.5" |> Maybe.andThen BigDecimal.toInt
--> Nothing
fromBigInt : BigInt -> BigDecimal

A BigInt, exactly and always.

toBigInt : BigDecimal -> BigInt

The whole part, truncated toward zero: -3.7 becomes -3, the same direction BigInt.fromFloat and Down go.

BigDecimal.fromString "-3.7" |> Maybe.map BigDecimal.toBigInt
--> Just (BigInt.fromInt -3)

For any other direction, apply roundTo 0 with the rounding you want first.

fromFloat : Float -> Maybe BigDecimal

The exact value of a Float, which is almost never the number that was typed to produce it. NaN and the infinities are Nothing. Everything else converts, because every finite double is an integer over a power of two, and a power of two is a terminating decimal.

BigDecimal.fromFloat 0.1 |> Maybe.map BigDecimal.toString
--> Just "0.1000000000000000055511151231257827021181583404541015625"

Those digits are not noise. That is the whole of the double, and this is the function that shows it to you, the same job BigInt.fromFloat does for 1.0e30.

That is also why this is the wrong function for a number a person wrote. fromString is the one for that:

BigDecimal.fromString "0.1" |> Maybe.map BigDecimal.toString
--> Just "0.1"
toFloat : BigDecimal -> Float

To a Float, rounding to the nearest one. Rounding is all a Float can do, and it is the reason this module exists.

Past a double's range the answer is an infinity rather than Nothing, because that is what the arithmetic would have given anyway. toString is the direction that never loses a digit, and toInt is the conversion that refuses.

Reading and writing

fromString : String -> Maybe BigDecimal

Read a decimal number: an optional sign, digits with an optional point somewhere among them, and an optional e exponent.

BigDecimal.fromString "-0.001" |> Maybe.map BigDecimal.toString
--> Just "-0.001"

BigDecimal.fromString "1.5e3" |> Maybe.map BigDecimal.toString
--> Just "1500"

The e may be either case and the exponent may carry its own sign, so 1.5E-3 is 0.0015 and 1e+5 is 100000. The exponent is not bounded: 1e-9999999999 is accepted, and would take ten billion digits to write. For text you did not write, fromStringWithin refuses that before building it. toString never writes an exponent back out; 1e-9 comes back as 0.000000001.

An underscore between two digits is ignored, as in BigInt.fromString, so 1_000.000_1 parses. One anywhere else, next to the point, the sign or the e, makes the whole string Nothing. Unlike that function there are no 0x, 0b or 0o prefixes; a hexadecimal fraction is not something anyone needs.

Anything else is Nothing rather than zero: no digits at all, a second point, an e with nothing after it, a trailing unit:

BigDecimal.fromString "1.2.3" --> Nothing

BigDecimal.fromString "3 apples" --> Nothing

The point is always . and there is no grouping separator but _, as in a Gren literal. A comma is not a digit, so 1,234.56 and 1.234,56 are both Nothing. That is deliberate: 1,234 is a thousand in one country and a bit over one in another, and no parser can tell from the text alone. Normalize a localized string before it gets here, where the locale is known:

german text =
    text
        |> String.replace "." ""
        |> String.replace "," "."
        |> BigDecimal.fromString

-- "1.234,56" --> Just 1234.56

The same two replacements cover most of Europe. Grouping varies more than the decimal separator does: French writes 1 234,56 with a space or a narrow no-break space, Swiss German writes 1'234.56, and Indian English groups unevenly as 12,34,567.89. Strip whatever the locale groups with, swap its decimal separator for ., and this function does the rest.

This is the function for a number a person wrote down. fromFloat tells you what a double is really holding, which is a different question.

toString : BigDecimal -> String

The number in full, with a leading - when negative and no exponent however far the point has to travel.

BigDecimal.fromString "1e-9" |> Maybe.map BigDecimal.toString
--> Just "0.000000001"

There are no trailing zeroes, because the value has none to write: toString of 1.50 is "1.5". toStringWithPlaces is the one that pads.

This is the lossless direction. A BigDecimal survives a round trip through a String and does not survive one through a Float, so if you are storing these anywhere, store the string.

toStringWithPlaces : Int -> BigDecimal -> String

The number with exactly this many decimal places, padded with zeroes if it has fewer and rounded half-even if it has more.

BigDecimal.fromString "1.5"
    |> Maybe.map (BigDecimal.toStringWithPlaces 2)
--> Just "1.50"

This is where significance lives. A BigDecimal does not remember that a price was quoted to the cent (1.50 and 1.5 are one value), so the number of places is something the formatter is told, by the code that knows what the number is for.

Half-even because it is the rounding that does not drift, which is the right default for a column of numbers and the wrong one for a receipt. toStringWithPlacesUsing is the same function with the rounding named.

A count of zero gives a whole number and no point, and a negative one rounds to tens or hundreds and writes the zeroes out.

toStringWithPlacesUsing : Int -> Rounding -> BigDecimal -> String

toStringWithPlaces with the rounding said out loud: toStringWithPlacesUsing places rounding value.

BigDecimal.fromString "2.345"
    |> Maybe.map (BigDecimal.toStringWithPlacesUsing 2 BigDecimal.HalfUp)
--> Just "2.35"

BigDecimal.fromString "2.345"
    |> Maybe.map (BigDecimal.toStringWithPlaces 2)
--> Just "2.34"

Both of those are correct, and they answer different questions. Half-even is the better default because ties fall both ways, so a long column of rounded numbers does not drift upwards; that is why it is the one the plain function uses, and why a report should keep it. But a price is not a column. Shops, invoices and most tax authorities round a half away from zero, and a total that disagrees with the arithmetic a customer did by hand is a support ticket whatever IEEE 754 has to say about it.

So HalfEven for a report, HalfUp for a receipt, and this is where you say which. Nothing is rounded but the text: use roundTo if the value itself should change, and note that a value rounded to places already will come out the same through either function.

Reading text you did not write

fromStringWithin : Int -> String -> Result Error BigDecimal

fromString with a ceiling on how many digits the number takes to write out, for text that comes from somewhere you do not control: fromStringWithin limit text.

The exponent is the hazard. 1e-9999999999 is thirteen characters and a perfectly small number, and it needs ten billion digits to hold. The plain fromString will try. This one works out from the text alone how many digits toString would have to write, and refuses before building anything if that is more than the limit:

BigDecimal.fromStringWithin 10 "0.00123"
    |> Result.map BigDecimal.toString
--> Ok "0.00123"

BigDecimal.fromStringWithin 10 "1e-9999999999"
--> Err (BigDecimal.TooLong { digits = 10000000000, limit = 10 })

BigDecimal.fromStringWithin 10 "1.2.3"
--> Err BigDecimal.NotANumber

The count is the digits of the written number on both sides of the point: 0.00123 is six, 1e3 is four, 1.5 is two, and zero is one. Leading and trailing zeroes in the text are not counted, because the value does not keep them. A limit below one refuses everything. Malformed text is NotANumber whatever its length.

type Error
= NotANumber
| TooLong ({ digits : Int, limit : Int })

Why fromStringWithin refused.

NotANumber is everything the plain fromString returns Nothing for. TooLong is a well-formed number that would take more digits to write out than the limit allows, and it says how many.

Arithmetic

add : BigDecimal -> BigDecimal -> BigDecimal

Add, exactly. The answer is carried at the finer of the two scales, which is the one that loses nothing.

No By suffix, unlike subBy, because addition is commutative, so there is no subject for a pipeline to put last.

sum : Array BigDecimal -> BigDecimal

Add up an array, exactly.

BigDecimal.sum
    [ BigDecimal.fromInt 1990 |> BigDecimal.movePointBy -2
    , BigDecimal.fromInt 495 |> BigDecimal.movePointBy -2
    ]
    |> BigDecimal.toString
--> "24.85"

A column of prices is the most common thing anyone does with these, and the reason it is worth a function of its own is that nothing here rounds: the answer does not depend on the order the values arrive in, which is exactly what a Float cannot promise.

sum of an empty array is zero.

subBy : BigDecimal -> BigDecimal -> BigDecimal

Subtract the first from the second: subBy b a is a - b.

a |> BigDecimal.subBy one
mul : BigDecimal -> BigDecimal -> BigDecimal

Multiply, exactly. Commutative, so no By.

A product of decimals always terminates: the scales add, and the digits are a BigInt multiplication. Exactness costs nothing here.

negate : BigDecimal -> BigDecimal

Flip the sign. The scale is untouched, so no arithmetic happens here at any size.

negate zero is zero; there is no negative zero.

abs : BigDecimal -> BigDecimal

Drop the sign. Like negate this cannot fail and cannot overflow, because a BigDecimal has no most negative value.

powBy : Int -> BigDecimal -> BigDecimal

Raise to a power: powBy exponent base. A non-negative exponent is exact however many digits it takes: the unscaled value goes through BigInt.powBy and the scale is multiplied.

BigDecimal.fromString "1.05" |> Maybe.map (BigDecimal.powBy 10)
--> BigDecimal.fromString "1.62889462677744140625"

A negative exponent is a reciprocal, and a reciprocal usually does not terminate. This gives the exact answer when there is one (powBy -3 (fromInt 2) is 0.125) and zero when there is not. That is the same shape of answer BigInt.powBy gives a negative exponent, and it comes with the same warning: if 3 ^ -1 should be 0.333... rather than nothing, divByTo is the function that will say so.

Dividing

There are two, because a decimal division either terminates or it does not, and only the caller knows which outcome is acceptable.

divBy : BigDecimal -> BigDecimal -> Maybe BigDecimal

Divide exactly, or not at all: divBy divisor dividend.

There are two reasons for Nothing, and the type does not distinguish them: the divisor was zero, or the quotient does not terminate in base ten. The second happens whenever the divisor keeps a prime factor other than two or five after the common factors cancel, so isZero on the divisor is how you tell the two apart when it matters.

-- 1 / 8 is 0.125, and stops
BigDecimal.one |> BigDecimal.divBy (BigDecimal.fromInt 8)
--> BigDecimal.fromString "0.125"

-- 1 / 3 does not
BigDecimal.one |> BigDecimal.divBy (BigDecimal.fromInt 3)
--> Nothing

Use this when a wrong answer is worse than no answer (splitting a total, converting a unit), and divByTo when you have a number of places in mind and are willing to say so.

divByTo :
Int
-> Rounding
-> BigDecimal
-> BigDecimal
-> Maybe BigDecimal

Divide to a fixed number of decimal places, rounding the way you say: divByTo places rounding divisor dividend.

This is the division that always has an answer, so the only Nothing is a zero divisor.

BigDecimal.one
    |> BigDecimal.divByTo 5 BigDecimal.HalfEven (BigDecimal.fromInt 3)
    |> Maybe.map BigDecimal.toString
--> Just "0.33333"

places is decimal places, not significant digits, and it may be negative: divByTo -3 HalfUp gives an answer to the nearest thousand. Trailing zeroes still come off afterwards, so a result that lands on 0.25 is 0.25 and not 0.25000; use toStringWithPlaces if you need the zeroes.

Splitting a total

Sometimes you need to divide a value into individual quantities that will sum up to the original value, always. You cannot round, you must share the remainder.

allocate : Int -> Int -> BigDecimal -> Maybe (Array BigDecimal)

Split a total into parts that add back up to it: allocate places parts total.

places says where the smallest unit is: 2 for a currency with cents, 0 for one without, and it may be negative to hand out whole hundreds.

BigDecimal.fromString "10.00"
    |> Maybe.andThen (BigDecimal.allocate 2 3)
    |> Maybe.map (Array.map BigDecimal.toString)
--> Just [ "3.34", "3.33", "3.33" ]

Compare what dividing gives you. divByTo 2 HalfUp makes every share 3.33, and three times 3.33 is 9.99; the cent is gone and no rounding mode brings it back. Here the parts differ by one unit at the most, the earliest ones take the extra, and the sum is the total you passed in.

There are two refusals, and both are refusals rather than roundings:

  • parts is less than one. There is nowhere to put the money.
  • total is not a whole number of units at places. allocate 2 will not split 10.005, because a third of a cent is not something to hand anybody. Round first and own the rounding: allocate 2 3 (roundTo 2 HalfUp total).

A negative total splits away from zero the same way, so the parts of negate total are the negated parts of total.

allocateBy :
Int
-> Array BigDecimal
-> BigDecimal
-> Maybe (Array BigDecimal)

Split a total in proportion to a set of weights, and still have the parts add back up to it: allocateBy places weights total.

This is the one a cart wants. An order-level discount comes off the lines, and it has to come off them exactly, or the lines no longer explain the total.

-- 10.00 off, shared out over three lines by what they cost
BigDecimal.fromString "10.00"
    |> Maybe.andThen (BigDecimal.allocateBy 2 [ line1, line2, line3 ])

Weights are relative, so [ 1, 1, 2 ] and [ 25, 25, 50 ] split alike and the scales they are written at do not matter. Each part is its exact share rounded toward zero, and the units left over go to the parts whose discarded fractions were biggest, earliest first. That is the largest-remainder method, and it is what keeps the parts within one unit of each other's fair share.

BigDecimal.fromString "0.05"
    |> Maybe.andThen
        (BigDecimal.allocateBy 2
            [ BigDecimal.one, BigDecimal.one, BigDecimal.one ]
        )
    |> Maybe.map (Array.map BigDecimal.toString)
--> Just [ "0.02", "0.02", "0.01" ]

Give every weight the same value and you get the split allocate makes. Like allocate, this refuses a total that is not a whole number of units at places. It also refuses weights that do not describe shares of anything: an empty array, a negative weight, or weights that add up to zero.

Rounding

type Rounding
= Up
| Down
| Ceiling
| Floor
| HalfUp
| HalfDown
| HalfEven

What to do with a digit that will not fit.

Seven of them, in three groups. The first two say which way to go regardless of how close the value was:

  • Up: away from zero. 2.4 and 2.6 both become 3.
  • Down: toward zero, which is truncation. 2.4 and 2.6 both become 2, and -2.6 becomes -2.

The next two depend on the sign of the number rather than on how close it was:

  • Ceiling: toward positive infinity. -2.6 becomes -2.
  • Floor: toward negative infinity. -2.4 becomes -3.

The last three all go to the nearer value and differ only on an exact tie:

  • HalfUp: ties away from zero. 2.5 becomes 3. This is the rounding taught in school, and the one most people mean.
  • HalfDown: ties toward zero. 2.5 becomes 2.
  • HalfEven: ties to the even neighbor. 2.5 becomes 2 and 3.5 becomes 4. Ties fall both ways instead of always up, so a long column of rounded numbers does not drift. This is banker's rounding, and it is what IEEE 754 does by default.
roundTo : Int -> Rounding -> BigDecimal -> BigDecimal

Round to a number of decimal places: roundTo places rounding value.

BigDecimal.fromString "2.675"
    |> Maybe.map (BigDecimal.roundTo 2 BigDecimal.HalfUp)
    |> Maybe.map BigDecimal.toString
--> Just "2.68"

That is the example a Float gets wrong, and not because of the rounding: the nearest double to 2.675 is slightly below it, so rounding that gives 2.67. Here the value is exactly 2.675 and the tie is a real tie.

Places may be negative, which rounds to tens or hundreds. Rounding to more places than a number has does nothing at all. There are no trailing zeroes to add, because a value never carries any; toStringWithPlaces is where padding lives.

Comparing

compare : BigDecimal -> BigDecimal -> Order

How the first compares to the second, the way Basics.compare does.

Because trailing zeroes are stripped, compare a b == EQ and a == b agree about everything, which is the property this module is arranged around.

isZero : BigDecimal -> Bool

Whether this is zero, which costs nothing at any size: normalization leaves exactly one zero, so this is a look at the unscaled value and nothing else.

isNegative : BigDecimal -> Bool

Zero is not negative.

isInteger : BigDecimal -> Bool

Whether there is nothing after the point.

The test is simply whether the scale is zero or less, and that only works because of normalization: a value that ends in a zero has already had it removed, so 1.50 arrives here as 1.5 and 2.00 as 2.

BigDecimal.fromString "2.00" |> Maybe.map BigDecimal.isInteger
--> Just True
max : BigDecimal -> BigDecimal -> BigDecimal

The larger of the two, standing in for Basics.max.

min : BigDecimal -> BigDecimal -> BigDecimal

The smaller of the two, standing in for Basics.min.

The representation

scale : BigDecimal -> Int

The power of ten the value is stored over: 1.5 has a scale of 1, and 100 has a scale of -2.

This is the canonical scale, not one you chose. Because trailing zeroes are stripped, it says how many decimal places the number actually needs, which is a fact about the value rather than about its history.

unscaled : BigDecimal -> BigInt

The digits, without the point: the BigInt that scale is the exponent for. Together they are the whole of the representation.

BigDecimal.fromString "-1.25" |> Maybe.map BigDecimal.unscaled
--> Just (BigInt.fromInt -125)
movePointBy : Int -> BigDecimal -> BigDecimal

Multiply by a power of ten by moving the point, not by multiplying: a positive count moves it right.

BigDecimal.fromString "1.5" |> Maybe.map (BigDecimal.movePointBy 2)
--> BigDecimal.fromString "150"

This costs nothing at any size, because only the scale changes.

A note on argument order

As in BigInt, wherever the order of the operands matters, the subject comes last:

a |> BigDecimal.subBy one -- a - 1

a |> BigDecimal.divBy b -- a / b

compare is the exception, because it stands in for Basics.compare.