Fixed point decimal numbers are used for representing all kinds of data: percentages, temperatures, distances, mass, and many others. I would like to share an approach for safely and efficiently representing currency data in Haskell with `safe-decimal`.

## Problems we want to solve

### Floating point

I wonder how much money gets misplaced because programmers choose a floating point type for representing money. I will not attempt to convince you that using `Double` or `Float` for monetary values is unacceptable, it is a known fact. Values like `NaN`, `+/-Infinity` and `+/-0` have no meaning in handling money. In addition, the inability to represent most decimal values exactly should be enough reason to avoid floating point.

### Fixed point decimal

Floating point types make sense when numerical approximation acceptable and you care primarily about performance rather than correctness. This is most common in numerical analysis, signal processing and other areas alike. In many other circumstances a type capable of representing decimal numbers exactly should be used instead. Unlike floating point in a `Decimal` type we manually restrict how many digits after the decimal point we can have. This is called fixed-point number representation. We use fixed-point numbers on a daily basis when paying in the store with cash or card, tracking distance with an odometer, and reading values off of a digital hydrometer or thermometer.

We can represent fixed-point decimal numbers in Haskell by using an integral type for the actual value, which is called a precision, and a scale parameter, which is used for keeping track of how far from the right the decimal point is. In `safe-decimal` we define a `Decimal` type that allows us to choose a precision (`p`) and supply our `s` scale parameter with the type level natural number:

``````newtype Decimal r (s :: Nat) p = Decimal p
deriving (Ord, Eq, NFData, Functor, Generic)
``````

Unlike floating point numbers we cannot move our decimal point without changing the scaling parameter and sometimes the precision as well. This means that when we use operations like multiplication or division we might have to do some rounding. The rounding strategy is selected at the type level with the `r` type variable. At time of writing the most common rounding strategies have been implemented: `RoundHalfEven`, `RoundHalfUp`, `RoundHalfDown`, `RoundDown` and `RoundToZero`. There is a plan to add more in the future.

### Precision

It is common to use a type like `Integer` for decimal representation, for straightforward reasons:

• `Integer` is easy to use
• `Integer` can represent any number in the universe, if you have enough memory

Let's look at an example which starts with enabling an extension in Haskell. We need to turn on `DataKinds` so that we can use type level natural numbers.

``````>>> :set -XDataKinds
>>> x = Decimal 12345 :: Decimal RoundHalfUp 4 Integer
>>> x
1.2345
>>> x * 5
6.1725
>>> roundDecimal (x * 5) :: Decimal RoundHalfUp 3 Integer
6.173
``````

The concrete `Decimal` type backed by `Integer` has a `Num` instance. That is why we were able to use literal `5` and GHC converted it to a `Decimal` for us. This is how the same numbers multiplied together look as `Double`:

``````>>> 1.2345 * 5 :: Double
6.172499999999999
``````

### Storage and Performance

`Integer` is nice, but in some applications `Integer` isn't an acceptable representation of our data. We might need to store decimal values in database, transmit them over the network, or improve performance by storing numbers in an unboxed instead of boxed array. It is faster to store a 64-bit integer value in a database rather than converting a number to a sequence of bytes in a blob as is necessary with `Integer`. Transmission over a network is another limitation that comes to mind. Having a 508 byte limit on a UDP packet can quickly become a problem for `Integer` based values.

The best way to solve this is to use fixed width integer types such as `Int64`, `Int32`, `Word64`, etc. If precision of more than 64 bits is desired there are packages that provide 128-bit, 256-bit, and other variants of signed/unsigned integers. All of them can be used with `safe-decimal`, eg:

``````>>> import Data.Int (Int8, Int64)
>>> Decimal 12345 :: Decimal RoundHalfUp 6 Int64
0.012345
>>> Decimal 123 :: Decimal RoundHalfUp 6 Int8
0.000123
``````

### Bounds

Even discarding the desire for better performance and ignoring the memory constraints imposed on us, there are often types that have domain-specific bounds anyway. The most common example is when people use signed types like `Int` to represent values that have no sensible negative value. Use unsigned types like `Word` for representing values that should have no negative value.

Some values that can be represented by a decimal number have a lower and upper bound that we estimate. Percentages go from 0% to a 100%, the total circulation of US dollars is about 14 trillion, and the surface temperature of a star is somewhere in a range of 225-40000K. If we use our domain specific knowledge we can come up with some safe bounds, instead of blindly assuming that we need infinitely large values.

Beware though, that using integral types with bounds come with real danger: integer overflow and underflow. These are common reasons for bugs in software that lead to a whole variety of exploits. This is the area where protection in `safe-decimal` really shines, and here is an example of how it protects you:

``````>>> 123 + 4 :: Int8
127
>>> 123 + 5 :: Int8
-128
>>> x = Decimal 123 :: Decimal RoundHalfUp 6 Int8
>>> x
0.000123
>>> plusDecimalBounded x (Decimal 4) :: Maybe (Decimal RoundHalfUp 6 Int8)
Just 0.000127
>>> plusDecimalBounded x (Decimal 5) :: Maybe (Decimal RoundHalfUp 6 Int8)
Nothing
``````

### Runtime exceptions

We know that division by zero will result in `DivideByZero` exception:

``````>>> 1 `div` 0 :: Int
*** Exception: divide by zero
``````

Less well known is that while some integral operations result in silent overflows, others will cause runtime exceptions:

``````>>> -1 * minBound :: Int
-9223372036854775808
>>> 1 `div` minBound :: Int
-1
>>> minBound `div` (-1) :: Int
*** Exception: arithmetic overflow
``````

Floating point values also have a sad story for division by zero. You'd be surprised how often you can stumble upon those values online: ``````>>> 0 / 0 :: Double
NaN
>>> 1 / 0 :: Double
Infinity
>>> -1 / 0 :: Double
-Infinity
``````

Long story short we want to be able to prevent all these issues from within pure code. Which is exactly what `safe-decimal` will do for you:

``````>>> -1 * pure minBound :: Arith (Decimal RoundHalfUp 2 Int)
ArithError arithmetic overflow
>>> pure minBound / (-1) :: Arith (Decimal RoundHalfUp 2 Int)
ArithError arithmetic overflow
>>> 1 / 0 :: Arith (Decimal RoundHalfUp 2 Int)
ArithError divide by zero
``````

`Arith` is a monad defined in `safe-decimal` and is used for working with arithmetic operations that can fail for any particular reason. It is isomorphic to `Either SomeException`, which means there is straightforward conversion from `Arith` monad to others that have `MonadThrow` instance with `arithM` and a few other helper functions:

``````>>> arithM (1 / 0 :: Arith (Decimal RoundHalfUp 2 Int))
*** Exception: divide by zero
>>> arithMaybe (1 / 0 :: Arith (Decimal RoundHalfUp 2 Int))
Nothing
``````

## Decimal for crypto

At the beginning of the post I mentioned that we will implement a currency. Everyone seems to be implementing cryptocurrencies nowadays, so why don't we do the same?

The most popular cryptocurrency at time of writing is Bitcoin, so we'll use it for this example. A few assumptions we are going to make before we start:

• The maximum amount is 21M BTC
• No negative amounts are allowed
• Precision is up to 8 decimal places
• Smallest expressible value is 0.00000001 BTC, which is one Satoshi. It is named after the pseudonymous Satoshi Nakamoto who published the seminal Bitcoin paper.

### Definition

Here we'll demonstrate how we can represent Bitcoin with `safe-decimal` and in case if you would like to follow along here is the gist with all of the code presented in this blogpost. First, we declare the raw amount `Satoshi` that will be used, so we can specify its bounds. Following that is the `Bitcoin` wrapper around the `Decimal` that specifies all we need to know in order to operate on this currency:

``````{-# LANGUAGE DataKinds #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

module Bitcoin (Bitcoin) where

import Data.Word
import Numeric.Decimal
import Data.Coerce

newtype Satoshi = Satoshi Word64 deriving (Show, Eq, Ord, Enum, Num, Real, Integral)

instance Bounded Satoshi where
minBound = Satoshi 0
maxBound = Satoshi 21_000_000_00000000

data NoRounding

type BitcoinDecimal = Decimal NoRounding 8 Satoshi

newtype Bitcoin = Bitcoin BitcoinDecimal deriving (Eq, Ord, Bounded)

instance Show Bitcoin where
show (Bitcoin b) = show b
``````

Important parts of these definitions are:

• We are using a newtype wrapper around `Word64` with custom bounds, so that the library can protect us from creating an invalid value. Using `Int64` would have not made a difference in this case, but using another type with less available bits would not be enough to hold large values.
• We define no rounding strategy to make sure that at no point rounding could cause money to appear or disappear.
• We do not export the constructor for `Bitcoin` type to ensure that invalid values cannot be constructed manually. Smart constructors will follow below, which can be exported if needed.

### Construction and arithmetic

Helper functions that do zero cost coercions from `Data.Coerce` will be used to go between types without making us repeat their signatures.

``````toBitcoin :: BitcoinDecimal -> Bitcoin
toBitcoin = coerce

fromBitcoin :: Bitcoin -> BitcoinDecimal
fromBitcoin = coerce

mkBitcoin :: MonadThrow m => Rational -> m Bitcoin
mkBitcoin r = Bitcoin <\$> fromRationalDecimalBoundedWithoutLoss r

plusBitcoins :: MonadThrow m => Bitcoin -> Bitcoin -> m Bitcoin
plusBitcoins b1 b2 = toBitcoin <\$> (fromBitcoin b1 `plusDecimalBounded` fromBitcoin b2)

minusBitcoins :: MonadThrow m => Bitcoin -> Bitcoin -> m Bitcoin
minusBitcoins b1 b2 = toBitcoin <\$> (fromBitcoin b1 `minusDecimalBounded` fromBitcoin b2)
``````

`mkBitcoin` gives us a way to construct new values, while giving us a freedom to choose the monad in which we want to fail by restricting to `MonadThrow`, for simplicity we'll stick to `IO`, but it could just as well be `Maybe`, `Either`, `Arith` and many others.

``````>>> mkBitcoin 1.23
1.23000000
>>> mkBitcoin (-1.23)
*** Exception: arithmetic underflow
``````

Examples below make it obvious that we are guarded from constructing invalid values from `Rational`:

``````>>> :set -XNumericUnderscores
>>> mkBitcoin 21_000_000.00000000
21000000.00000000
>>> mkBitcoin 21_000_000.00000001
*** Exception: arithmetic overflow
>>> mkBitcoin 0.123456789
*** Exception: PrecisionLoss (123456789 % 1000000000) to 8 decimal spaces
``````

Same logic goes for operating on `Bitcoin` values. Nothing gets past, any operation that could produce an invalid value will result in a failure.

``````>>> balance <- mkBitcoin 10.05
12.39500000
*** Exception: arithmetic overflow
>>> arithEither \$ plusBitcoins balance maliciousReceiveBitcoin
Left arithmetic overflow
``````

Subtracting values is handled in the same fashion. Note that going below a lower bound will be reported as underflow, which, contrary to popular belief, is a real term not only for floating points, but for integers as well.

``````>>> balance <- mkBitcoin 10.05
>>> sendAmount <- mkBitcoin 1.01
>>> balance `minusBitcoins` sendAmount
9.04000000
>>> sendAmountTooMuch <- mkBitcoin 11.01
>>> balance `minusBitcoins` sendAmountTooMuch
*** Exception: arithmetic underflow
>>> sendAmountMalicious <- mkBitcoin 184467440737.09551616
*** Exception: arithmetic overflow
``````

I would like to emphasize in the example above the fact that we did not have to check if `balance` was sufficient enough for the amounts to be fully deducted from it. This means we are automatically protected from incorrect transactions as well as very common attack vectors, some of which really did happen with Bitcoin and other cryptocurrencies.

### Num and Fractional

Using a special smart constructor is cool and all, but it would be cooler if we could use our regular math operators to work with `Bitcoin` values and utilize GHC desugarer to automatically convert numeric literal values too. For this we need instances of `Num` and `Fractional`. We can't create instances like that:

``````instance Num Bitcoin where
...
instance Fractional Bitcoin where
...
``````

because then we would have to use partial functions for failures, which is exactly what we want to avoid. Moreover some functions simply do no make sense for monetary values. Multiplying or dividing Bitcoins together, is simply undefined. We'll have to represent a special type of failure through an exception. This is a bit unfortunate, but we'll go with it anyways:

``````data UnsupportedOperation =
UnsupportedMultiplication | UnsupportedDivision
deriving Show

instance Exception UnsupportedOperation

instance Num (Arith Bitcoin) where
(+) = bindM2 plusBitcoins
(-) = bindM2 minusBitcoins
(*) = bindM2 (\_ _ -> throwM UnsupportedMultiplication)
abs = id
signum mb = fmap toBitcoin . signumDecimalBounded . fromBitcoin =<< mb
fromInteger i = toBitcoin <\$> fromIntegerDecimalBoundedIntegral i

instance Fractional (Arith Bitcoin) where
(/) = bindM2 (\_ _ -> throwM UnsupportedDivision)
fromRational = mkBitcoin
``````

It is important to note that defining the instances above is strictly optional and exporting helper functions that perform the same operations is preferable. We have the instances now so we can demonstrate their use:

``````>>> 7.8 + 10 - 0.4 :: Arith Bitcoin
Arith 17.40000000
>>> 7.8 - 10 + 0.4 :: Arith Bitcoin
ArithError arithmetic underflow
>>> 7.8 * 10 / 0.4 :: Arith Bitcoin
ArithError UnsupportedMultiplication
>>> 7.8 / 10 * 0.4 :: Arith Bitcoin
ArithError UnsupportedDivision
>>> 7.8 - 7.7 + 0.4 :: Arith Bitcoin
Arith 0.50000000
>>> 0.4 - 7.7 + 7.8 :: Arith Bitcoin
ArithError arithmetic underflow
``````

The order of operations can play tricks on you, which probably serves as another reason to stick to exporting functions: `mkBitcoin`, `plusBitcoins`, `minusBitcoins` and whatever other operations we might need.

Let's take a look at a more realistic example where the amount sent is supplied to us as a `Scientific` value likely from some JSON object and we want to update the balance of our account. For simplicity's sake I will use a `State` monad, but same approach will work just as well with whatever stateful setup you have.

``````newtype Balance = Balance Bitcoin deriving Show

sendBitcoin :: MonadThrow m => Balance -> Scientific -> m (Bitcoin, Balance)
sendBitcoin startingBalance rawAmount =
flip runStateT startingBalance \$ do
amount <- toBitcoin <\$> fromScientificDecimalBounded rawAmount
Balance balance <- get
newBalance <- minusBitcoins balance amount
put \$ Balance newBalance
pure amount
``````

Usage of this simple function will demonstrate us the power of the approach taken in the library as well as its limitations:

``````>>> balance <- mkBitcoin 10.05
>>> sendBitcoin (Balance balance) 0.5
(0.50000000,Balance 9.55000000)
>>> sendBitcoin (Balance balance) 1e-6
(0.00000100,Balance 10.04999900)
>>> sendBitcoin (Balance balance) 1e+6
*** Exception: arithmetic underflow
>>> arithEither \$ sendBitcoin (Balance balance) (-1)
Left arithmetic underflow
``````

We witness `Overflow`/`Underflow` errors as expected, but we get almost no information on where exactly the problem occurred and which value was responsible for it. This is something that can be fixed with customized exceptions, but for now we do achieve the most important goal, namely protecting our calculations from all the dangerous problems without doing any explicit checking.

Nowhere in `sendBitcoin` did we have to validate our input, output, or intermediate values. Not a single `if then else` statement. This is because all of the information needed to determine the validity of the above operations was encoded into the type and the library enforces that validity for the programmer.

### Mixing Decimal types

Although multiplying two `Bitcoin` values makes no sense, computing the product of an amount and a percentage makes perfect sense. So, how do we go about multiplying different decimals together?

While demonstrating interoperability of different decimal types we'd like to also show how higher precision integrals can be used with `Decimal`. In this example we'll use a `Word128` backed `Decimal` for computing future value. There are a couple of packages that provide 128-bit integral types and it doesn't matter which one it comes from.

Our goal is to compute the savings account balance at 1.9% APY (Annual Percentage Yield) in 30 days if you start with 10,000 BTC and add 10 BTC each day.

We will start by defining the rounding strategy implementation for the `Word128` type and specifying the `Decimal` type we will be using for computation:

``````instance Round RoundHalfUp Word128 where
roundDecimal = roundHalfUp

type CDecimal = Decimal RoundHalfUp 33 Word128
``````

This is not the implementation of `FV` (Future Value) function as it is known in finance. It is a direct translation of how we think the accrual of interest works. In plain English we can say that to compute balance of the account tomorrow, we take balance we have today, multiply it by the daily interest rate and add it to the today's balance together with the amount we promised to top up daily.

``````futureValue :: MonadThrow m => CDecimal -> CDecimal -> CDecimal -> Int -> m CDecimal
futureValue startBalance dailyRefill apy days = do
dailyScale <- -- apy is in % and the year of 2020 is a leap year
fromIntegralDecimalBounded (100 * 366)
dailyRate <- divideDecimalBoundedWithRounding apy dailyScale
let go curBalance day
| day < days = do
accruedDaily <- timesDecimalBoundedWithRounding curBalance dailyRate
nextDayBalance <- sumDecimalBounded [curBalance, accruedDaily, dailyRefill]
go nextDayBalance (day + 1)
| otherwise = pure curBalance
go startBalance 0
``````

The above implementation works on the `CDecimal` type. What we need to calculate is `Bitcoin`. This means we have to do some type conversions and scaling in order to match up the types of `futureValue` function. Then we do some rounding and conversion again to reduce precision to obtain the new `Balance`:

``````futureValueBitcoin :: MonadThrow m => Balance -> Bitcoin -> Rational -> Int -> m (Balance, CDecimal)
futureValueBitcoin (Balance (Bitcoin balance)) (Bitcoin dailyRefill) apy days = do
balance' <- scaleUpBounded (fromIntegral <\$> castRounding balance)
dailyRefill' <- scaleUpBounded (fromIntegral <\$> castRounding dailyRefill)
apy' <- fromRationalDecimalBoundedWithoutLoss apy
endBalance <- futureValue balance' dailyRefill' apy' days
endBalanceRounded <- integralDecimalToDecimalBounded (roundDecimal endBalance)
pure (Balance \$ Bitcoin \$ castRounding endBalanceRounded, endBalance)
``````

Now we can compute what our balance will be in 30 days:

``````computeBalance :: Arith (Balance, CDecimal)
computeBalance = do
balance <- Balance <\$> 10000
topup <- 10
futureValueBitcoin balance topup 1.9 30
``````

Let's see what values we get and how they compares to the actual `FV` function that works on `Double` (for the curious here is one possible implementation numpy.fv)

``````>>> fst <\$> arithM computeBalance
Balance 10315.81142818
>>> fv (1.9 / 36600) 30 (-10) (-10000)
10315.811428177167
``````

That's pretty good. We get the accurately rounded result of our new balance. But how accurate is the computed result before the rounding is applied? As accurate as 128 bits can do in presence of rounding:

``````>>> snd <\$> arithM computeBalance
10315.811428176906130029412612348658890
``````

We get much better accuracy here than we could with `Double`. This isn't surprising, since we have more bits at our disposal, but accuracy is not the only benefit of this calculation. The result is also deterministic! This is practically impossible to guarantee with floating point number calculations across different platforms and architectures.

## Available solutions

A very common question people usually ask when a new library is being announced: "What is wrong with currently available solutions?". That is a perfectly reasonable question, which hopefully we have a compelling answer for.

We had a strong requirement for safety, correctness, and performance. Which is the combination that none of the available libraries in Haskell ecosystem could provide.

I will use `Data.Fixed` from `base` as an example and list some of limitations that prevented us from using it:

• Backed by `Integer`, which makes it slower than it should be for common cases.

• Truncation instead of some more useful rounding strategies.

``````>>> 5.39 :: Fixed E1
5.3
>>> 5.499999999999 :: Fixed E1
5.4
``````
• No built-in protection against runtime exceptions:
``````>>> f = 5.49 :: Fixed E1
>>> f / 0
*** Exception: divide by zero
``````
• There is a limited number of scaling types: `E0`, `E1`, `E2`, `E3`, `E6`, `E9` and `E12`. It is possible to add new ones with `HasResolution`, but it is a bit inconvenient.

• No built-in ability to specify bounds. This means that there is no protection against things like negative values or going outside of artificially imposed limits.

Similar arguments can be applied to other libraries. Especially the objection regarding performance. This objection is not unfounded: our benchmarks have revealed performance issues of practical relevance with existing implementations.

## Conclusion

I encourage everyone who writes software for finance, blockchain and other areas that require exact precision and safety of calculations, to seriously consider all implications of choosing the wrong data type for representing their numeric values.

Haskell is a very safe language out of the box, but as you saw in this post, it does not offer the desired level of safety when it comes to operations on numeric values. Hopefully we were able to convince you, that, at least for decimal numbers, such safety can be achieved with `safe-decimal` library.

If you feel like this post describes problems that are familiar to you and you are looking for a solution, please reach out to us and we will be glad to help.

Email subscriptions come from our Atom feed and are handled by Blogtrottr. You will only receive notifications of blog posts, and can unsubscribe any time.

Do you like this blog post and need help with Next Generation Software Engineering, Platform Engineering or Blockchain & Smart Contracts? Contact us.