Chapter 8

IEEE 754 and Number Precision: The Real Reason 0.1+0.2 Fails

0.1 + 0.2 equals 0.30000000000000004 in JavaScript, but 0.1 + 0.7 equals exactly 0.8. Both are floating-point operations โ€” why are the results so different? The answer lives in every single bit of the IEEE 754 standard.

๐Ÿ”น Level 1 ยท What You Need to Know

JavaScript Has Only One Number Type

All JavaScript numbers โ€” integers, decimals, large numbers โ€” are represented as 64-bit IEEE 754 double-precision floating-point values. This is mandated by the ECMAScript specification; there is no separate int type.

// All of these are the same type: 64-bit float
typeof 42          // "number"
typeof 3.14        // "number"
typeof NaN         // "number"
typeof Infinity    // "number"
typeof -0          // "number"
typeof 1e300       // "number"

// Safe integer range
Number.MAX_SAFE_INTEGER  // 9007199254740991 (= 2^53 - 1)
Number.MIN_SAFE_INTEGER  // -9007199254740991 (= -(2^53 - 1))

// Beyond the safe range, integer precision starts to break
9007199254740991 + 1    // 9007199254740992 (correct)
9007199254740991 + 2    // 9007199254740992 (wrong! should be 9007199254740993)
9007199254740992 + 1    // 9007199254740992 (the 1 disappears!)

Checking the Safe Integer Range

// Check if a number is a safe integer
Number.isSafeInteger(9007199254740991)  // true
Number.isSafeInteger(9007199254740992)  // false

// Real-world scenario: database IDs, timestamps, etc.
// โŒ Dangerous: integer IDs above 2^53 lose precision
const bigId = 9007199254740993;
console.log(bigId);  // 9007199254740992 (precision lost!)

// โœ… Correct: use BigInt for large integers
const safeBigId = 9007199254740993n;
console.log(safeBigId);  // 9007199254740993n (exact)

// โœ… Or store as string
const idAsString = "9007199254740993";

Correct Floating-Point Comparison

// โŒ Never directly compare floating-point results
0.1 + 0.2 === 0.3      // false!

// โœ… Method 1: use Number.EPSILON (~2.22 ร— 10^-16) as error tolerance
function almostEqual(a, b) {
  return Math.abs(a - b) < Number.EPSILON;
}
almostEqual(0.1 + 0.2, 0.3)  // true

// โœ… Method 2: business-appropriate epsilon
function nearlyEqual(a, b, epsilon = 1e-9) {
  return Math.abs(a - b) < epsilon;
}

// โœ… Method 3: financial math โ€” use integer cents (most reliable)
function addMoney(a, b) {
  // a = $0.10, b = $0.20
  const cents = Math.round(a * 100) + Math.round(b * 100);
  return cents / 100;  // $0.30
}
addMoney(0.1, 0.2)  // 0.3

// โœ… Method 4: toFixed for display only (not for calculation)
(0.1 + 0.2).toFixed(1)  // "0.3" (a string!)

Correct Financial Calculation

// โŒ Wrong: using floats directly for financial math
const price = 1.1;          // $1.10
const quantity = 3;
const total = price * quantity;  // 3.3000000000000003!

// โœ… Correct approach 1: use cents (integers) throughout
class Money {
  constructor(cents) {
    this.cents = Math.round(cents); // unit: cents (integer)
  }
  add(other) {
    return new Money(this.cents + other.cents);
  }
  multiply(n) {
    return new Money(Math.round(this.cents * n));
  }
  toString() {
    return "$" + (this.cents / 100).toFixed(2);
  }
}

const price2 = new Money(110);  // $1.10 = 110 cents
const total2 = price2.multiply(3);
console.log(total2.toString());  // "$3.30"

// โœ… Correct approach 2: use decimal.js library (npm install decimal.js)
// import Decimal from 'decimal.js';
// new Decimal('0.1').plus('0.2').toString()  // "0.3" (exact)

๐Ÿ”ธ Level 2 ยท How It Actually Runs

The 64-Bit Structure of IEEE 754 Double Precision

64-bit double memory layout:

 63      62        52 51                                    0
  โ”Œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚ S โ”‚   Exponent    โ”‚              Mantissa                โ”‚
  โ”‚ 1 โ”‚    11 bits    โ”‚               52 bits                โ”‚
  โ””โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
   โ†‘          โ†‘                         โ†‘
  Sign     Exponent                  Mantissa
           (bias 1023)            (significant digits)

S (Sign): 0 = positive, 1 = negative

Exponent (11 bits):
  Stored value = actual exponent + 1023 (biased encoding)
  Stored range: 0-2047
  Actual exponent range: -1022 to +1023 (0 and 2047 are special)

Mantissa (52 bits):
  Actual significant digits are 1.mantissa (implicit leading 1,
  except when exponent is 0)
  Equivalent to 53 bits of effective precision (1 + 52)

Value formula:
  value = (-1)^S ร— 2^(E-1023) ร— 1.M
  where E is the stored exponent value, M is the mantissa bits

0.1 in Binary โ€” Representation and Truncation

Converting 0.1 from decimal to binary:

0.1 ร— 2 = 0.2 โ†’ integer 0, remainder 0.2
0.2 ร— 2 = 0.4 โ†’ integer 0, remainder 0.4
0.4 ร— 2 = 0.8 โ†’ integer 0, remainder 0.8
0.8 ร— 2 = 1.6 โ†’ integer 1, remainder 0.6
0.6 ร— 2 = 1.2 โ†’ integer 1, remainder 0.2  โ† cycle begins!
0.2 ร— 2 = 0.4 โ†’ integer 0, remainder 0.4
... (infinite repeating)

0.1 in binary (infinitely repeating):
0.0001100110011001100110011001100110011001100110011...
   ^^^^
   these 4 bits are 0.0625 = 1/16, then "0011" repeats

Stored in double (truncated after 53 significant bits):
  0.1 actual stored value โ‰ˆ 0.1000000000000000055511151231257827...

0.2 similarly (actual stored value slightly above 0.2):
  0.2 actual stored value โ‰ˆ 0.200000000000000011102230246251565...

0.1 + 0.2: adding two "slightly too large" values
  โ‰ˆ 0.30000000000000004440892098500626...
  Displayed as: 0.30000000000000004

0.3 stored directly:
  โ‰ˆ 0.299999999999999988897769753748434...
  Note: 0.3's stored value is slightly LESS than 0.3!

So 0.1 + 0.2 (slightly above 0.3) != 0.3 (slightly below 0.3)

Why 0.1 + 0.7 === 0.8

0.7's binary truncation:
  0.7 actual stored value is slightly less than 0.7

0.1 (slightly too large) + 0.7 (slightly too small) โ†’ errors cancel out
  The resulting sum happens to equal 0.8's stored bit pattern

This is a "lucky coincidence" in floating-point arithmetic:
Different numbers have truncation errors in different directions โ€”
sometimes they cancel each other out, sometimes they accumulate.

Special Bit Patterns: NaN, -0, ยฑInfinity

Special value IEEE 754 bit patterns:

Value        S    Exponent (11 bits)  Mantissa (52 bits)
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
+Infinity    0    11111111111         0...0 (all zeros)
-Infinity    1    11111111111         0...0 (all zeros)
NaN          x    11111111111         non-zero (any)
+0           0    00000000000         0...0 (all zeros)
-0           1    00000000000         0...0 (all zeros)

NaN uniqueness:
  There are 2^52 - 1 = 4503599627370495 distinct NaN bit patterns
  The JavaScript spec treats all of them as a single NaN value
  NaN !== NaN is required by IEEE 754 โ€” not a JS bug

-0 uniqueness:
  -0 === +0 is true (per spec)
  But 1 / -0 === -Infinity, 1 / +0 === +Infinity
  Object.is(-0, +0) is false (Object.is distinguishes them)

๐Ÿ”บ Level 3 ยท How the Spec Defines It

6.1.6.1 The Number Type

Spec text (ECMA-262, Section 6.1.6.1):

6.1.6.1 The Number Type

The Number type has exactly 18437736874454810627 (that is, 2^64 โˆ’ 2^53 + 3) values, representing the double-precision 64-bit format IEEE 754-2019 values as specified in the IEEE Standard for Binary Floating-Point Arithmetic, except that the 9007199254740990 (that is, 2^53 โˆ’ 2) distinct "Not-a-Number" values of the IEEE Standard are represented in ECMAScript as a single special NaN value. (Note that the NaN value is produced by the program expression NaN.) In some implementations, external code might be able to detect a difference between various Not-a-Number values, but such behaviour is implementation-defined; to ECMAScript code, all NaN values are indistinguishable from each other.

Key numbers explained:

Spec Definitions of Math Functions

The spec defines Math.floor, Math.ceil, Math.round in terms of an abstract concept: the mathematical real value (written as โ„(x)):

21.3.2.16 Math.floor ( x )

This function returns the largest (closest to +โˆž) integral Number value that is not greater than x. If x is already an integral Number, the return value is x.

  • If x is NaN, +โˆž๐”ฝ, or -โˆž๐”ฝ, return x.
  • If x is +0๐”ฝ or -0๐”ฝ, return x.
  • If x < -0๐”ฝ and x > -1๐”ฝ, return -0๐”ฝ. โ† notice this!

Special case: Math.floor(-0.5) returns -0 because -0.5 < -0 and -0.5 > -1, matching the third rule.

Math.floor(-0.5)   // -0 (not -1!)
Math.floor(-0.0)   // -0
Math.floor(0.0)    // 0

// Verification
Object.is(Math.floor(-0.5), -0)  // true
Object.is(Math.floor(-0.5), 0)   // false

Number.prototype.toFixed Precision Issues

Even toFixed has precision traps, since the spec's implementation is based on decimal string representation of the stored double:

// Surprising toFixed behavior
(1.005).toFixed(2)    // "1.00" not "1.01"!
// Reason: 1.005 in double is actually 1.00499999...

(1.255).toFixed(2)    // "1.25" (same issue)
(1.355).toFixed(2)    // "1.35"

// More reliable rounding
function round(num, digits) {
  const factor = Math.pow(10, digits);
  return Math.round(num * factor) / factor;
}
round(1.005, 2)  // 1.01 (but still depends on multiplication precision)

// Most reliable method: string + BigInt (for financial scenarios)
function preciseRound(numStr, digits) {
  // numStr: number as string, e.g. "1.005"
  const [int, dec = ""] = numStr.split(".");
  const padded = dec.padEnd(digits + 1, "0").slice(0, digits + 1);
  const lastDigit = parseInt(padded[digits]);
  const bigVal = BigInt(int + padded.slice(0, digits)) + (lastDigit >= 5 ? 1n : 0n);
  const result = bigVal.toString();
  const intPart = result.slice(0, -digits) || "0";
  const decPart = result.slice(-digits).padStart(digits, "0");
  return `${intPart}.${decPart}`;
}
preciseRound("1.005", 2)  // "1.01" (correct!)

๐Ÿ’Ž Level 4 ยท Edge Cases and Traps

Trap 1: 0.1 + 0.2 !== 0.3 but 0.1 + 0.7 === 0.8

// The bit-level truth
// You can inspect the exact decimal value of a float like this:
function toExactDecimal(n) {
  return n.toPrecision(21);  // 21 significant digits reveals all precision differences
}

toExactDecimal(0.1)    // "0.100000000000000005551"
toExactDecimal(0.2)    // "0.200000000000000011102"
toExactDecimal(0.3)    // "0.299999999999999988898"
toExactDecimal(0.1+0.2)// "0.300000000000000044409"

// 0.1 + 0.2 (about +4.4e-17 above true value) != 0.3 (about -1.1e-17 below) โ†’ not equal

toExactDecimal(0.7)    // "0.699999999999999955591"
toExactDecimal(0.8)    // "0.800000000000000044409"
toExactDecimal(0.1+0.7)// "0.800000000000000044409"

// 0.1 + 0.7 produces the exact same bit pattern as 0.8 โ†’ equal!

Trap 2: Integer Arithmetic Losing Precision

// 2^53 = 9007199254740992
const MAX_SAFE = Number.MAX_SAFE_INTEGER;  // 9007199254740991

// Within safe range: exact
MAX_SAFE + 1   // 9007199254740992 (correct)
MAX_SAFE + 2   // 9007199254740992 (wrong! should be 9007199254740993)

// Why? Above 2^53, adjacent representable double values are spaced 2 apart
// 9007199254740993 cannot be exactly represented โ€” rounded to 9007199254740992

// Spacing grows with magnitude
2**53           // 9007199254740992
2**53 + 1       // 9007199254740992 (1 disappears)
2**54           // 18014398509481984
2**54 + 1       // 18014398509481984 (spacing is 2, 1 disappears)
2**54 + 2       // 18014398509481984 (spacing is 2, 2 disappears!)
2**54 + 4       // 18014398509481988 (first representable difference)

// Real-world consequence
const userId = 9007199254740993;  // large ID from backend
console.log(userId === 9007199254740992);  // true! The ID was silently changed

Trap 3: Detecting -0

// -0 exists
const negZero = -0;
const posZero = 0;

// Ordinary comparisons say they're equal
negZero === posZero        // true
negZero == posZero         // true

// But they are not the same value
1 / negZero                // -Infinity
1 / posZero                // +Infinity

// Correct way to detect -0
Object.is(negZero, -0)     // true
Object.is(posZero, -0)     // false
Object.is(negZero, 0)      // false

// Traditional method (using division)
function isNegativeZero(n) {
  return n === 0 && 1/n === -Infinity;
}
isNegativeZero(-0)  // true
isNegativeZero(0)   // false

// When does -0 appear?
Math.sign(-0)        // -0 (sign is preserved)
-0 * 2               // -0
-0 / 2               // -0
-0 + (-0)            // -0
-0 + 0               // 0 (positive zero!)
-1 * 0               // -0
Math.round(-0.4)     // -0

// The -0 trap in real business code
const speed = -0;  // result of some physics calculation
if (speed < 0) {
  console.log("Moving left");
} else {
  console.log("Moving right"); // โ† this branch executes! because -0 < 0 is false
}
// Correct approach: use Object.is
if (Object.is(speed, -0)) {
  console.log("Stationary with negative direction");
}

Trap 4: NaN Detection and Propagation

// NaN's special property: the only value not equal to itself
NaN === NaN    // false (required by IEEE 754)
NaN !== NaN    // true

// Global isNaN vs Number.isNaN
isNaN(NaN)        // true (correct)
isNaN("hello")    // true! ("hello" is converted to Number โ†’ NaN)
isNaN(undefined)  // true! (undefined โ†’ NaN)
isNaN({})         // true! ({} โ†’ NaN)
isNaN(null)       // false (null โ†’ 0)

Number.isNaN(NaN)        // true (correct)
Number.isNaN("hello")    // false (no type coercion!)
Number.isNaN(undefined)  // false
Number.isNaN({})         // false
Number.isNaN(null)       // false

// NaN propagation
NaN + 1         // NaN
NaN * 0         // NaN (not 0!)
NaN ** 0        // 1 (exception! anything^0 is 1, even NaN)
Math.max(NaN, 1)// NaN
[1, NaN, 3].reduce((a, b) => a + b)  // NaN (one NaN poisons the whole chain)

// Filtering NaN
const nums = [1, NaN, 3, NaN, 5];
const clean = nums.filter(Number.isFinite);  // [1, 3, 5]
// Note: Number.isFinite filters both NaN and Infinity

Trap 5: Complete Financial Calculation Solution

// A minimal financial calculation class
class Decimal {
  // Internally uses integer units with 10 decimal places of precision
  constructor(value) {
    if (typeof value === 'string') {
      // Parse string directly to avoid float error
      const parts = value.split('.');
      const intPart = BigInt(parts[0]);
      const decStr = (parts[1] || '').padEnd(10, '0').slice(0, 10);
      this._value = intPart * 10000000000n + BigInt(decStr);
    } else if (typeof value === 'number') {
      // Float โ†’ string โ†’ parse (error already happened, but minimize it)
      this._value = BigInt(Math.round(value * 10000000000));
    }
    this._scale = 10000000000n; // 10^10, 10 decimal places of precision
  }

  add(other) {
    const result = new Decimal(0);
    result._value = this._value + other._value;
    return result;
  }

  multiply(other) {
    const result = new Decimal(0);
    result._value = (this._value * other._value) / this._scale;
    return result;
  }

  toString() {
    const absVal = this._value < 0n ? -this._value : this._value;
    const sign = this._value < 0n ? '-' : '';
    const str = absVal.toString().padStart(11, '0');
    const intPart = str.slice(0, -10) || '0';
    const decPart = str.slice(-10).replace(/0+$/, '') || '0';
    return `${sign}${intPart}.${decPart}`;
  }
}

// Usage
const price = new Decimal('0.1');
const qty = new Decimal('3');
const tax = new Decimal('0.07');

const subtotal = price.multiply(qty);
const taxAmount = subtotal.multiply(tax);
console.log(subtotal.toString());   // "0.3"
console.log(taxAmount.toString());  // "0.021"

Chapter Summary

  1. All JavaScript numbers are IEEE 754 64-bit double-precision floats: 53 bits of significant precision, safe integer range ยฑ(2^53-1) = ยฑ9007199254740991. Integers beyond this range silently lose precision with no error.

  2. The root cause of 0.1 + 0.2 !== 0.3: both 0.1 and 0.2 are infinitely repeating in binary, so truncation produces errors (both slightly above the true value). Their sum's error is larger than the direct storage error of 0.3, and in the opposite direction โ€” so the result doesn't equal 0.3's stored bit pattern.

  3. -0 exists and is meaningful: -0 === 0 is true, but 1/-0 === -Infinity. Use Object.is(x, -0) or 1/x === -Infinity to distinguish -0 from 0. Physics simulations and direction calculations need to be aware of this.

  4. Number.isNaN is superior to global isNaN: the global isNaN coerces types first (isNaN("hello") is true); Number.isNaN is strict (only actual NaN returns true). NaN propagates through arithmetic, "poisoning" entire calculation chains.

  5. Financial calculations must use integers or a dedicated library: never use floating-point arithmetic directly for money. Options: integer cent units (Math.round(amount * 100), all integer arithmetic), BigInt for exact arithmetic, or professional libraries like decimal.js/big.js. (1.005).toFixed(2) returning "1.00" instead of "1.01" is a classic precision trap.

Rate this chapter
4.8  / 5  (46 ratings)

๐Ÿ’ฌ Comments