Codementor Events

Exact Calculations in JavaScript

Published Jul 11, 2024
Exact Calculations in JavaScript

Whoever is careless with the truth in small matters cannot be trusted with important matters. ~ Albert Einstein

Exactitude, the quality of being very accurate and careful, is a mindset. It requires that nothing should be assumed and everything should be proven. This principle is especially important in programming, where precision is key.

Floating-Point Arithmetic and Exactitude

We all know that under the common math system, 1 + 1 = 2. Computers understand this as well. Every positive integer can be represented in any integer base greater than 1, including binary. This means some combination of the positive powers of 2 (1, 2, 4, 8, 16, 32, ...) can represent a digital integer. Integers can be added, subtracted, and multiplied with exact methods.

However, division introduces complications. For division, we need real values or floating-point numbers. Floating-point numbers use negative powers of 2 (1/2, 1/4, 1/8, 1/16, 1/32, ...), which leads to a problematic situation: we cannot guarantee exactness anymore.

Consider the number 0.1. Let's try to represent it in binary. A quick search shows that its exact binary representation is infinitely repeating. This means that numbers like 0.1 cannot be expressed exactly in binary, nor can most numbers be represented exactly in 32, 64, or any finite number of bits.

The Problem of Rounding in JavaScript

Despite these challenges, calculators and computers manage to give us seemingly exact results like 0.1 + 0.1 = 0.2. This is achieved through resolution and rounding. In JavaScript, the global Math object has a round function, which rounds a floating-point number to the nearest integer. However, this method is limited to a resolution of zero digits after the decimal.

Let's look at a simple rounding function in JavaScript that rounds to a specified resolution:

const round = (num, step) => Math.round(num / step) * step;

// Examples
console.log(round(1, 1));     //  1
console.log(round(0.5, 1));   //  1
console.log(round(0.49, 1));  //  0
console.log(round(-2, 1));    // -2
console.log(round(1, 0.5));   //  1
console.log(round(1.1, 0.5)); //  1
console.log(round(2.2, 0.5)); //  2
console.log(round(2.49, 0.5));//  2.5

While this works for many cases, it fails for others due to the inherent limitations of floating-point arithmetic. Consider the following:

console.log(round(0.6, 0.1)); // 0.6000000000000001
console.log(round(1, 0.1));   // 1
console.log(round(1.1, 0.1)); // 1.1
console.log(round(2.2, 0.1)); // 2.2
console.log(round(2.49, 0.1));// 2.5

The issue arises because 0.1 cannot be exactly represented in binary, leading to errors when performing arithmetic operations.

Improving the Rounding Function

To address these issues, we need a more robust method that considers resolution and mitigates floating-point errors. Here's an improved version:

const round = (n, k, resolution) => {
    const precision = Math.pow(10, Math.trunc(resolution));
    return Math.round(
        Math.round(((n + Number.EPSILON) * precision) / k) * k
    ) / precision;
}

// Examples
console.log(round(1.005, 1, 2)); // 1.01
console.log(round(0.6, 1, 1));   // 0.6

Explanation:

  1. Move the decimal: Shift the decimal point to the right by multiplying by 10^resolution.
  2. Scale down: Divide the result by k.
  3. First round: Round to the nearest integer.
  4. Scale up: Multiply by k.
  5. Second round: Round to the nearest integer again.
  6. Move the decimal back: Shift the decimal point back to the left by dividing by 10^resolution.

The Role of Number.EPSILON

Number.EPSILON is the smallest difference between 1 and the next representable floating-point number. By adding Number.EPSILON before the rounding operations, we compensate for small representation errors. This technique ensures that our rounding function provides accurate results within the limitations of floating-point arithmetic.

Practical Applications

With a robust rounding method, we can create helpers for various scenarios, such as currency calculations:

const cents = (value) => round(value, 1, 2); // Rounds to 2 decimal places

// For many forex applications, which often require 5 decimal places
const forex = (value) => round(value, 1, 5);

// To round to the nearest 5 cents, as required in some countries
const cents5 = (value) => round(value, 5, 2);

// To take a percentage of a total currency value
const percent = (value, percentage) => cents(value * percentage / 100);

// To sum currency values
const sum = (numbers) => numbers.reduce((a, b) => a + b, 0);
const centsSum = (numbers) => cents(sum(numbers));

Summary

Floating-point rounding is more challenging than it appears, particularly when exact results are required. By using the Number.EPSILON approach, we can achieve reliable results for most practical applications, including financial computations. While exactitude in floating-point arithmetic is inherently limited, careful rounding ensures precision where it matters most.

Discover and read more posts from Tony Reijm
get started