Sick of the stupid jokes? Write your own arbitrary-precision JavaScript math library
JavaScript has its fair share of ‘wat’ moments. Even though most of them have a logical explanation once you dig in, they can still be surprising. But JavaScript doesn’t deserve all the indignant laughter. For example, you’ll sometimes see jokes like this:
In what language does 0.1 + 0.2 not equal 0.3?
console.log(0.1 + 0.2 === 0.3);
// ⦘ false
console.log(0.1 + 0.2);
// ⦘ '0.30000000000000004'
In JavaScript! Hahahaha. What a stupid language.
In this case the criticism is entirely undeserved. JavaScript, like nearly every other popular programming language, represents numbers using a standard. To be precise, the IEEE 754 standard for double-precision 64-bit binary format numbers. Let’s try that same joke in some other languages:
How about Ruby?
In what language does 0.1 + 0.2 not equal 0.3?
$ irb
irb(main):001:0> 0.1 + 0.2 == 0.3
=> false
irb(main):002:0> 0.1 + 0.2
=> 0.30000000000000004
In Ruby! Hahahaha. What a stupid language.
Or Clojure?
In what language does 0.1 + 0.2 not equal 0.3?
$ clj
Clojure 1.10.1
user=> (== (+ 0.1 0.2) 0.3)
false
user=> (+ 0.1 0.2)
0.30000000000000004
In Clojure! Hahahaha. What a stupid language.
Or how about the mighty Haskell?
In what language does 0.1 + 0.2 not equal 0.3?
$ ghci
GHCi, version 8.10.1: https://www.haskell.org/ghc/ :? for help
Prelude> 0.1 + 0.2 == 0.3
False
Prelude> 0.1 + 0.2
0.30000000000000004
In Haskell! Hahahaha. What a stupid language.
You get the idea. The issue here isn’t JavaScript. It’s the bigger problem of representing floating point numbers in binary. But I don’t want to get into the details of IEEE 754 for the moment. Because, if we need arbitrary precision numbers, JavaScript now makes that possible. Since October-ish 2019, BigInt
is officially part of the TC39 ECMAScript standard.
Why bother?
We’ve gotten by with IEEE 754 for ages. It doesn’t seem to be a problem most of the time. That’s true. It isn’t a problem most of the time. But on occasion, it is. And in those moments, it’s good to have options.
For example, I was working on a chart library earlier this year. I wanted to draw candlestick charts in SVG. And SVG has this neat feature called a transform
. You can apply it to a group of elements, and it will change the coordinate system for those elements. So, with a little care, you can simplify generating the chart area. Instead of calculating chart coordinates for each candlestick, you specify a single transform. And then specify each candlestick using raw data values. It’s neat. At least, in theory.
But in my property tests, I was running into problems. If the chart was small, and the data values were large, I’d get rounding errors. And most of the time, that’s OK. But in a chart, certain pixels have to line up. Otherwise it doesn’t look right. So I started to look into BigInt
. The outcome was a library I’ve called ‘Ratio’. And I’m going to show you how you could write it too.
The Ratio class
The problem with floating point numbers is binary representation. Computers do all their calculations in binary. And binary is fine for integers. The trouble comes when we want to represent decimal numbers. For example, in English-speaking countries like Australia, we write decimal numbers like this:
The bit to the left the dot ( ) is the integer part. And the bit to the right of the dot is the fractional part. But the trouble is, some numbers have fractional parts that don’t divide easily into two. So they’re hard to represent in binary. But we even have the similar problems working in base 10. For example, consider. the fraction . You can try to write it something like this:
That’s an approximation, though. To represent with full accuracy, those ones have to go on forever. So we have to use some other notation to represent the repeated ones. Like the dot notation:
That dot over the one indicates the ones keep on going. But we don’t have dot notation in most programming languages.
Notice though, that has perfect accuracy. And all it takes is two pieces of information. That is a numerator and a denominator. With a single BigInt
value we can represent arbitrarily large integers. But if we create a pair of integers, we can represent arbitrarily large or small numbers.1
In JavaScript, that might look like this:
// file: ratio.js
export default class Ratio {
// We expect n and d to be BigInt values.
constructor(n, d) {
this.numerator = n;
this.denominator = d;
}
}
And with that, we’ve done the trickiest bit. We’ve ‘invented’ a way to represent numbers with near-infinite accuracy. (We’re still limited by the amount of memory in our devices). All that remains is to apply some mathematics. Stuff you might have studied in school.
So let’s add some features.
Equals
The first thing we want to do is compare two ratios. Why? Because I like to write my code test-first. If I can compare two ratios for equality, then it’s much easier to write tests.
For the simple case, writing an equality method is pretty easy:
// file: ratio.js
export default class Ratio {
constructor(n, d) {
this.numerator = n;
this.denominator = d;
}
equals(other) {
return (
this.numerator === other.numerator &&
this.denominator === other.denominator
);
}
}
That’s fine. But it would be nice if our library could tell if, say, was equal to . To do that, we need to simplify our ratios. That is, before we test for equality, we want to reduce both ratios to the smallest possible integers. So, how do we do that?
A naïve approach is to run through all the numbers from 1 to (where and are the numerator and denominator). And that’s what I tried first. It looked something like this:
function simplify(numerator, denominator) {
const maxfac = Math.min(numerator, denominator);
for (let i=2; i<=maxfac; i++) {
if ((numerator % i === 0) && (denominator % i === 0)) {
return simplify(numerator / i, denominator / i);
}
}
return Ratio(numerator, denominator);
}
And, as you’d expect, it’s ridiculously slow. My property tests took ages to run. So, we need a more efficient approach. Lucky for us, a Greek mathematician figured this one out a couple of millennia ago. The way to solve it is using Euclid’s algorithm. It’s a way of finding the largest common factor for two integers.
The recursive version of Euclid’s algorithm is beautiful and elegant:
function gcd(a, b) {
return (b === 0) ? a : gcd(b, a % b);
}
It can be memoized too, making it quite snappy. But alas, we don’t have tail call recursion in V8 or SpiderMonkey yet. (At least, not at the time of writing). This means that if we run it with large enough integers, we get stack overflow. And large integers are kind of the point here.
So, instead, we use the iterative version:
// file: ratio.js
function gcd(a, b) {
let t;
while (b !== 0) {
t = b;
b = a % b;
a = t;
}
return a;
}
Not so elegant, but it does the job. And with that in place, we can write a function to simplify ratios. While we’re at it, we’ll make a small modification so that denominators are always positive. (That is, only the numerator changes sign for negative numbers).
// file: ratio.js
function sign(x) {
return x === BigInt(0) ? BigInt(0)
: x > BigInt(0) ? BigInt(1)
/* otherwise */ : BigInt(-1);
}
function abs(x) {
return x < BigInt(0) ? x * BigInt(-1) : x;
}
function simplify(numerator, denominator) {
const sgn = sign(numerator) * sign(denominator);
const n = abs(numerator);
const d = abs(denominator);
const f = gcd(n, d);
return new Ratio((sgn * n) / f, d / f);
}
And with that it place, we can write our equality method:
// file: ratio.js -- inside the class declaration
equals(other) {
const a = simplify(this);
const b = simplify(other);
return (
a.numerator === b.numerator &&
a.denominator === b.denominator
);
}
We’re now able to compare two ratios for equality. It might not seem like much, but it means we can write unit tests and make sure our library works as expected.
Converting to other types
Now, I won’t bore you by writing out all the unit tests for this library. But something that would be nice is to convert these ratios into other formats. For example, we might want to represent them as a string in debug messages. Or we might want to convert them to numbers. So let’s override the .toString()
and .toValue()
methods for our class.
The .toString()
method is easiest, so let’s start with that.
// file: ratio.js -- inside the class declaration
toString() {
return `${this.numerator}/${this.denominator}`;
}
Easy enough. But how about converting back to a number? One way to do it is to just divide numerator by denominator:
// file: ratio.js -- inside the class declaration
toValue() {
return Number(this.numerator) / Number(this.denominator);
}
That works, most of the time. But we might want to tweak it a little. The whole point of our library is that we use large integers to get the precision we need. And sometimes these integers will be too large to convert back to a Number. But, we want to get the number as close as we can, wherever possible. So we do a little bit of arithmetic when we convert:
// file: ratio.js -- inside the class declaration
toValue() {
const intPart = this.numerator / this.denominator;
return (
Number(this.numerator - intPart * this.denominator) /
Number(this.denominator) + Number(intPart)
);
}
By extracting the integer part, we reduce the size of the BigInt values before we convert them to Number. There are other ways to do this that have fewer range problems. In general, they are more complex and slower though. I encourage you to look into them further if you’re interested. But for this article, the simple approach will cover enough cases to be useful.
Multiply and divide
Let’s do something with our numbers. How about multiplication and division? These are not complicated for ratios. For multiplication, we multiply numerators with numerators, and denominators with denominators.
// file: ratio.js -- inside the class declaration
times(x) {
return simplify(
x.numerator * this.numerator,
x.denominator * this.denominator
);
}
Division is similar. We invert the second ratio, then multiply.
// file: ratio.js -- inside the class declaration
divideBy(x) {
return simplify(
this.numerator * x.denominator,
this.denominator * x.numerator
);
}
Add and subtract
We now have multiplication and division. The next logical thing to write is addition and subtraction. These are slightly more complicated than multiplication and division. But not too much.
To add two ratios together, we first need to manipulate them so they have the same denominator. Then we add the numerators together. In code, that might look something like this:
// file: ratio.js -- inside the class declaration
add(x) {
return simplify(
this.numerator * x.denominator + x.numerator * this.denominator,
this.denominator * x.denominator
);
}
Everything is multiplied by denominators. And we use simplify()
to keep the ratios as small as possible.
Subtraction is similar. We manipulate the two ratios so that denominators line up as before. Then we subtract instead of adding the numerators.
// file: ratio.js -- inside the class declaration
subtract(x) {
return simplify(
this.numerator * x.denominator - x.numerator * this.denominator,
this.denominator * x.denominator
);
}
So we have our basic operators. We can add, subtract, multiply and divide. But we still need a few other methods. In particular, numbers have an important property: we can compare them to each other.
Less than and greater than
We’ve already discussed .equals()
. But we need more than just equality. We’d also like to be able to tell if one ratio is larger or smaller than another. So we’ll create a method .lte()
that will tell us if a ratio is less than or equal to another ratio. Like .equals()
, it’s not obvious which of two ratios is smaller. To compare them, we need to convert both to have the same denominator. Then, we can compare numerators to see which is larger. With a bit of simplification, it might look like so:
// file: ratio.js -- inside the class declaration
lte(other) {
const { numerator: thisN, denominator: thisD } = simplify(
this.numerator,
this.denominator
);
const { numerator: otherN, denominator: otherD } = simplify(
other.numerator,
other.denominator
);
return thisN * otherD <= otherN * thisD;
}
Once we have .lte()
and .equals()
we can derive all the other comparisons. We could have chosen any comparison operator. But once we have equals()
and any one of , , or , then we can derive the others with boolean logic. In this case, we’ve gone with lte()
because that’s what the FantasyLand standard uses. Here’s how working out the others might look.
// file: ratio.js -- inside the class declaration
lt(other) {
return this.lte(other) && !this.equals(other);
}
gt(other) {
return !this.lte(other);
}
gte(other) {
return this.gt(other) || this.equals(other);
}
Floor and ceiling
We can now compare ratios. And we can also multiply and divide, add and subtract. But if we’re going to do more interesting things with our library, we need more tools. Some of the handy ones from JavaScript’s Math
object include .floor()
and .ceil()
.
We’ll start with .floor()
. Floor takes a value and rounds it down. With positive numbers, that means we just keep the integer part and throw away any remainder. But for negative numbers, we round away from zero, so it needs a little extra care.
// file: ratio.js -- inside the class declaration
floor() {
const one = new Ratio(BigInt(1), BigInt(0));
const trunc = simplify(this.numerator / this.denominator, BigInt(1));
if (this.gte(one) || trunc.equals(this)) {
return trunc;
}
return trunc.minus(one);
}
With that in place, we can leverage it to help us calculate ceiling values. This is where we round up.
// file: ratio.js -- inside the class declaration
ceil() {
const one = new Ratio(BigInt(1), BigInt(0));
return this.equals(this.floor()) ? this : this.floor().add(one);
}
We now have most of what we’d need for lots of math operations. And with .toValue()
we can easily convert our calculations back to decimal numbers. But what if we want to convert a floating point number to a ratio?
Numbers to Ratios
Converting a number to a ratio is more involved than it might seem at first glance. And there are many different ways to do it. The way I’ve done it is not the most accurate, but it’s good enough. To make it work, we first convert the number to a string we know will be in a consistent format. For this, JavaScript gives us the .toExponential()
method. It gives us the number in exponential notation. Here’s some examples so you get the idea:
let x = 12.345;
console.log(x.toExponential(5));
// ⦘ '1.23450e+1''
x = 0.000000000042;
console.log(x.toExponential(3));
// ⦘ '4.200e-11'
x = 123456789;
console.log(x.toExponential(4));
// ⦘ '1.2346e+8'
It works by representing the number as a normalised decimal value and a multiplier. We call the normalised decimal bit the significand. And the multiplier, the exponent. Here, ‘normalised’ means the absolute value of the significand is always less than 10. And the exponent is always a power of 10. We indicate the start of the multiplier with the letter ‘e’, short for ‘exponent’.
The advantage of this notation is that it’s consistent. There’s always exactly one digit to the left of the decimal point. And .toExponential()
lets us specify how many significant digits we want. Then comes the ‘e’ and the exponent (always an integer). Because it’s so consistent, we can use a cheeky regular expression to parse it.
The process goes something like this. As mentioned, .toExponential()
takes a parameter to specify the number of significant digits. We want maximum digits. So we set the precision to 100 (which is as many as most JavaScript engines will allow). For this example though, we’ll stick with a precision of 10. Now, imagine we have a number like 0.987654321e0
. What we want to do is move that decimal point 10 digits to the right. That would give us 9876543210
. Then we divide it by , and we get . This, in turn, simplifies to .
We have to pay attention to that exponent though. If we have a number like 0.987654321e9
, we still move the decimal point 10 digits to the right. But we divide by ten to the power of .
To make all this happen, we define a couple of helper functions:
// Transform a ‘+’ or ‘-‘ character to +1 or -1
function pm(c) {
return parseFloat(c + "1");
}
// Create a new bigint of 10^n. This turns out to be a bit
// faster than multiplying.
function exp10(n) {
return BigInt(`1${[...new Array(n)].map(() => 0).join("")}`);
}
With those in place we can put the whole fromNumber()
function together.
// file: ratio.js -- inside the class declaration
static fromNumber(x) {
const expParse = /(-?\d)\.(\d+)e([-+])(\d+)/;
const [, n, decimals, sgn, pow] =
x.toExponential(PRECISION).match(expParse) || [];
const exp = PRECISION - pm(sgn) * +pow;
return exp < 0
? simplify(BigInt(`${n}${decimals}`) * exp10(-1 * exp), BigInt(1))
: simplify(BigInt(`${n}${decimals}`), exp10(exp));
}
We now have most of the basic functions covered. We can go from numbers to ratios, and back again. For my particular application though, I needed more. In particular, I needed to find exponents and logarithms.
Exponentiation
Exponentiation is where you multiply something by itself repeatedly. For example . For simple cases where the exponent is an integer, we already have a built-in BigInt operator: **
. So, if we’re taking our rato to the power of an integer, we’re good to go. The power law for ratios looks like so:
Hence, a first cut of our exponentiation method might look something like this:
// file: ratio.js -- inside the class declaration
pow(exponent) {
if (exponent.denominator === BigInt(1)) {
return simplify(
this.numerator ** exponent.numerator,
this.denominator ** exponent.numerator
);
}
}
That works fine. Well… mostly fine. Things start to get tricky from here. Because of the limits of hardware and mathematics, we have to make some compromises. We might have to sacrifice precision for the sake of getting an answer in a reasonable amount of time.
With exponentiation it’s not hard to generate very large numbers. And when numbers get large, everything slows down. While writing this article I created calculations that ran for days without finishing. So we need to be careful. But that’s OK. It comes with the territory for BigInt.
There is another problem though. What do we do if the denominator of the exponent is not 1? For example, what if we wanted to calculate ?
Fortunately, we can split this problem up into two parts. We want to take one ratio to the power of another. For example, we might take to the power of . The laws of exponentiation say that the following are equivalent:
We already know how to take a BigInt to the power of another BigInt. But what about the fractional power? Well, there’s another equivalence we can bring in here:
That is, taking to the power of is equivalent to finding the n root of . This means, if we can find a way to calculate the n root of a BigInt, then we can calculate any power.
With a well-crafted web-search or two, it doesn’t take long to find an algorithm for estimating the n root. The most common is Newton’s method. It works by starting with an estimate, . Then we make the following calculation to get a better estimate:
We keep repeating that calculation until we reach the desired precision. Unfortunately, there are some roots that can’t be represented as a finite fraction. To put it another way, to get perfect precision, we’d need infinitely long BigInt values. In practice, this means we have to pick an arbitrary limit on how many iterations we’ll do.
We’ll come back to this point. For now, let’s work out how we can calculate a good estimate of the n root. Because the estimate will be a ratio, we can write it as:
And that allows us to rewrite the estimate calculation as:
This puts it in a form where everything is in terms of integer calculations suitable for use with BigInt. Feel free to plug into the equation for above and check my derivation. Putting that into JavaScript looks something like the following:
const estimate = [...new Array(NUM_ITERATIONS)].reduce(r => {
return simplify(
(n - BigInt(1)) * r.numerator ** n + x * r.denominator ** n,
n * r.denominator * r.numerator ** (n - BigInt(1))
);
}, INITIAL_ESTIMATE);
We just repeat that calculation until we reach a suitable accuracy for our n root estimate. The trouble is, we need to come up with suitable values for our constants. That is, NUM_ITERATIONS
and INITIAL_ESTIMATE
.
A lot of algorithms start with their INITIAL_ESTIMATE
as 1. It’s a reasonable choice. Most of the time we have no real good way of guessing what the n root might be. But in our case, we can cheat. Let’s assume (for the moment) that our numerator and denominator are in the range allowed by Number
. We can then use Math.pow()
to get an initial estimate. That might look like so:
// Get an initial estimate using floating point math
// Recall that x is a bigint value and n is the desired root.
const initialEstimate = Ratio.fromNumber(
Math.pow(Number(x), 1 / Number(n))
);
So we have a value for our initial estimate. But what about NUM_ITERATIONS
? Well, in practice, what I did was start with a guess of 10. And then I would run my property tests. I kept dialling the number back until they finished in a reasonable amount of time. And the figure that finally worked was… 1. One iteration. Which makes me a little sad, but we are at least a little more accurate than floating point calculations. In practice, you can tune this number number up if you’re not calculating a lot of fractional powers.
To keep things simple, we’ll pull the n root calculation out into its own function. Putting it all together it might look like the following:
// file: ratio.js -- inside the class declaration
static nthRoot(x, n) {
// Handle special cases
if (x === BigInt(1)) return new Ratio(BigInt(1), BigInt(1));
if (x === BigInt(0)) return new Ratio(BigInt(0), BigInt(1));
if (x < 0) return new Ratio(BigInt(1), BigInt(0)); // Infinity
// Get an initial estimate using floating point math
const initialEstimate = Ratio.fromNumber(
Math.pow(Number(x), 1 / Number(n))
);
const NUM_ITERATIONS = 1;
return [...new Array(NUM_ITERATIONS)].reduce((r) => {
return simplify(
n -
BigInt(1) * (r.numerator ** n) +
x * (r.denominator ** n),
n * r.denominator * r.numerator ** (n - BigInt(1))
);
}, initialEstimate);
}
pow(n) {
const { numerator: nNumerator, denominator: nDenominator } = n.simplify();
const { numerator, denominator } = this.simplify();
if (nNumerator < 0) return this.invert().pow(n.abs());
if (nNumerator === BigInt(0)) return Ratio.one;
if (nDenominator === BigInt(1)) {
return new Ratio(numerator ** nNumerator, denominator ** nNumerator);
}
if (numerator < 0 && nDenominator !== BigInt(1)) {
return Ratio.infinity;
}
const { numerator: newN, denominator: newD } = Ratio.nthRoot(
numerator,
nDenominator
).divideBy(Ratio.nthRoot(denominator, nDenominator));
return new Ratio(newN ** nNumerator, newD ** nNumerator);
}
It’s not perfect, and it’s slow. But it gets the job done. Well, mostly. There’s still the issue of how to get an estimate if we have integers larger than Number.MAX_VALUE
. I’ll leave that as an exercise to the reader though, as this article is already far too long.
Logarithms
I have to admit, logarithms stumped me for weeks. For the thing I’m building, I need to calculate logarithms in base 10. So I went searching for algorithms to calculate logs. And there’s plenty of them. But I couldn’t find one that worked well enough to be included in a math library.
Why is it so hard? My goal was to calculate logarithms to be more accurate than floating point. Otherwise, why bother? The floating point log function, Math.log10()
, is fast and built-in. So, I looked at algorithms that provided ways to iteratively calculate logarithms. And they work. But to get accuracy higher than floating point, they’re slow. Not just a little bit slow. Very slow.
What happens is that as we go through the iterations, the fraction we build gets more and more accurate. But that accuracy comes at a cost. The BigInt values in our fraction become larger and larger. And as they get larger, multiplying them together starts to take a long time. At one point I left a calculation running for three days. But while that calculation was running, I remembered something.
I remembered that I wanted the log10()
method so that I could calculate nice scale values for charts. And for those calculations, every time I called .log10()
, I would immediately call .floor()
. Which means I only need the integer part of the log. Calculating the logarithm to 100 decimal places was just a waste of effort.
Better yet, there’s a simple way to calculate the integer part of a base 10 logarithm. All we need to do is count the digits. A naïve attempt might look like the following:
// file: ratio.js -- inside the class declaration
floorLog10() {
return simplify(BigInt((this.numerator / this.denominator).toString().length - 1), BigInt(1));
}
Unfortunately, that doesn’t work for values less than one. But even then, we can use some logarithm laws to work around it.
Therefore:
Putting it all together, we get a more robust floorLog10()
method:
// file: ratio.js -- inside the class declaration
invert() {
return simplify(this.denominator, this.numerator);
}
floorLog10() {
if (this.equals(simplify(BigInt(0), BigInt(1)))) {
return new Ratio(BigInt(-1), BigInt(0));
}
return this.numerator >= this.denominator
? simplify((this.numerator / this.denominator).toString().length - 1, 1)
: simplify(BigInt(-1), BigInt(1)).subtract(this.invert().floorLog10());
}
Again. Why bother?
At this point the library has all the functions I need for my charting application. But you might still be wondering, why go to all this trouble? There’s already several arbitrary precision libraries around. Why not just use one of those and be done with it?
To be fair, most of the time I would use an existing library. Especially if I’m in a hurry. There’s no point in doing all this work if someone else has already done a superior job.
The key word there is ‘superior’ though. And that’s where my motivations for wanting to write my own library come into play. The floorLog10()
method above is the perfect case study. For what I want to do, it provides the precise calculation I need. It does it efficiently, in about six lines of code.
If I were to use someone else’s library I’d face one of two scenarios:
- They don’t implement a
log10()
or any other logarithm methods; or - They do implement a
log10()
method (or equivalent).
In the first scenario, I’d end up having to write floorLog10()
anyway. In the second scenario, I’d probably end up using their logarithm method. And my code would have been slower and more complex than it needed to be.
Writing my own library allows me to tailor it to the application. Sure, other people might find it useful, but I’m not beholden to their needs. So my application doesn’t have to carry around complex code it never uses.
Besides all that, I learned a lot writing my own library. I now understand the practical limitations of BigInt much better than before. I know that I can tune the performance of my n root method. I can tweak it depending on how many calculations I’m running and what accuracy I need.
Sometimes it’s worth writing your own general purpose library. Even if you don’t plan to open-source it. Even if nobody else ever uses it. You can learn a lot and, besides, it can be fun.
Finally, if you’re interested in finding out more about the problems with floating point numbers, check out https://0.30000000000000004.com. And if you want to see the library all together and making some calculations, you can check out this code sandbox.