GAP·MAP

Numbers & precision

[ junior depth ]

Why 0.1 + 0.2 is not exactly 0.3

JavaScript Number values use the IEEE 754 double-precision binary format. Decimal source text is converted to the nearest available binary value. Some fractions fit exactly in base 2. Others repeat forever and must be rounded.

0.5 + 0.25 === 0.75; // true: halves and quarters have exact binary forms
0.1 + 0.2;           // 0.30000000000000004
0.1 + 0.2 === 0.3;   // false

The operator is performing Number addition correctly. The inputs and result are constrained to the representable Number values.

Wrong "JavaScript cannot do decimal arithmetic."

Right JavaScript can operate on decimal-looking Number literals, but many decimal fractions are approximations in a finite binary format.

When an application compares measured values, it often needs a tolerance chosen for the data. Number.EPSILON describes the gap between 1 and the next greater representable Number. It is useful near magnitude 1, but it is not a universal tolerance.

function closeEnough(a, b, tolerance) {
  return Math.abs(a - b) <= tolerance;
}

closeEnough(0.1 + 0.2, 0.3, 1e-12); // true

The caller chooses tolerance from the measurement precision and expected magnitude. Exact integers and identifiers should still use exact equality when they are represented safely.

Safe integers have a smaller range than finite Numbers

Number.MAX_SAFE_INTEGER is 9007199254740991, or 2 ** 53 - 1. Its negative counterpart is Number.MIN_SAFE_INTEGER. Within that inclusive range, integer values can be represented exactly and compared correctly.

const x = Number.MAX_SAFE_INTEGER + 1;
const y = Number.MAX_SAFE_INTEGER + 2;

x;                  // 9007199254740992
y;                  // 9007199254740992
x === y;            // true
Number.isSafeInteger(x); // false

Number.MAX_VALUE answers a different question. It is the largest finite Number, around 1.7976931348623157e308; it does not promise integer precision.

Use BigInt when an integer may exceed the safe range and arithmetic must remain exact. Create it from the original digits, before a Number conversion can lose information.

const id = BigInt("9007199254740993");
id + 1n; // 9007199254740994n

BigInt represents integers only. Most arithmetic operators reject a mix of BigInt and Number operands.

1n + 1;  // TypeError
5n / 2n; // 2n: the fractional part is discarded

NaN and Infinity are Number values

Invalid numeric operations can produce NaN. Division of a nonzero Number by zero produces positive or negative Infinity.

0 / 0;       // NaN
1 / 0;       // Infinity
-1 / 0;      // -Infinity
NaN === NaN; // false

Use the non-coercing Number predicates when checking a parsed or computed result.

Number.isNaN(NaN);          // true
Number.isNaN("not a number"); // false
Number.isFinite(42);        // true
Number.isFinite(Infinity);  // false
Number.isFinite("42");     // false

The global isNaN() and isFinite() functions coerce their inputs first, so strings and null can produce surprising answers.

Keep money in exact minor units

Repeated arithmetic on dollar Numbers can carry binary approximation through a calculation. For a fixed two-decimal USD boundary, parse decimal text directly into integer cents and store the currency with the amount.

function parseUsdCents(text) {
  const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(text);
  if (!match) throw new RangeError("Expected a non-negative USD amount");

  const dollars = BigInt(match[1]);
  const cents = BigInt((match[2] ?? "").padEnd(2, "0"));
  return dollars * 100n + cents;
}

const charge = {
  currency: "USD",
  minorUnits: parseUsdCents("19.90"), // 1990n
};

This parser has a narrow contract: non-negative USD amounts with at most two fractional digits. Other currencies and inputs need their own validated scale and sign rules. Formatting is a separate boundary; a formatted currency string is display output, not the stored amount.

[ middle depth ]

A Number is one of a finite set of binary64 values

ECMAScript defines Number using the IEEE 754 double-precision binary format. Precision is 53 significant binary bits. The spacing between representable values grows with magnitude, which explains both fractional drift and the safe-integer boundary.

The source literals 0.1, 0.2, and 0.3 are each converted to representable binary values. Addition produces another rounded representable value.

const sum = 0.1 + 0.2;

sum;              // 0.30000000000000004
sum === 0.3;      // false
sum.toFixed(2);   // "0.30"

toFixed(2) returns a string. It changes how the stored Number is rendered, not the arithmetic that produced it. More requested digits can expose the approximation:

(0.3).toFixed(50);
// "0.29999999999999998889776975374843459576368331909180"

Wrong "Call toFixed(2) after every operation to make financial arithmetic exact."

Right toFixed() formats an existing Number. Choose an exact representation and a rounding policy for the calculation itself.

For approximate data, equality depends on the domain. A fixed absolute tolerance works only when the expected magnitudes and input accuracy make it appropriate.

function approximatelyEqual(a, b, absoluteTolerance, relativeTolerance) {
  if (a === b) return true;
  if (!Number.isFinite(a) || !Number.isFinite(b)) return false;

  if (
    !Number.isFinite(absoluteTolerance) || absoluteTolerance < 0 ||
    !Number.isFinite(relativeTolerance) || relativeTolerance < 0
  ) {
    throw new RangeError("Tolerances must be finite and non-negative");
  }

  const difference = Math.abs(a - b);
  const scale = Math.max(Math.abs(a), Math.abs(b));
  return difference <= absoluteTolerance || difference / scale <= relativeTolerance;
}

approximatelyEqual(1000.1 + 1000.2, 2000.3, 1e-12, 1e-15); // true

The tolerances are part of the API contract. Number.EPSILON alone is usually too small away from magnitude 1, and blindly adding it before rounding does not define a general correction.

Detect unsafe integers at the boundary

A safe integer is an integer that has one unambiguous Number representation. The inclusive range is -(2 ** 53 - 1) through 2 ** 53 - 1.

function requireSafeInteger(value, name) {
  if (!Number.isSafeInteger(value)) {
    throw new RangeError(`${name} must be a safe integer`);
  }
  return value;
}

requireSafeInteger(9_007_199_254_740_991, "sequence"); // accepted
requireSafeInteger(9_007_199_254_740_992, "sequence"); // RangeError

An unsafe Number cannot be repaired by converting it to BigInt. The conversion preserves the Number value after the earlier rounding.

const roundedFirst = 9007199254740993;
roundedFirst;         // 9007199254740992
BigInt(roundedFirst); // 9007199254740992n

const exact = BigInt("9007199254740993");
exact;                // 9007199254740993n

BigInt fits counters, identifiers, timestamps with a range beyond safe integers, and exact minor-unit balances. It does not represent fractions. Arithmetic operands usually must share the same numeric type, and division truncates toward zero.

7n / 3n; // 2n
7n / 3;  // TypeError

JSON serialization also needs an explicit boundary because JSON.stringify() does not serialize a BigInt by default. A decimal string preserves the digits across ordinary JSON parsers.

const payload = JSON.stringify({
  sequence: 9007199254740993n.toString(),
});

// '{"sequence":"9007199254740993"}'

Validate special values before they spread

NaN, positive Infinity, and negative Infinity belong to the Number type. typeof NaN is therefore "number". Most arithmetic involving NaN produces NaN, so validation is most useful where text or external data first becomes a Number.

function parseFiniteNumber(text) {
  const value = Number(text);
  if (!Number.isFinite(value)) {
    throw new RangeError("Expected a finite number");
  }
  return value;
}

parseFiniteNumber("42.5"); // 42.5
parseFiniteNumber("oops"); // RangeError because Number("oops") is NaN
parseFiniteNumber("1e999"); // RangeError because the result is Infinity

Number.isNaN(value) answers the narrower question: is this value the Number value NaN? It does not coerce. The global predicates do coerce, which is why isFinite(null) is true while Number.isFinite(null) is false.

Money needs an amount model and a rounding point

Store an amount with its currency. For a known fixed scale, integer minor units remove binary fractions from addition and subtraction.

const invoice = {
  currency: "USD",
  subtotalMinor: 10_00n,
  taxMinor: 82n,
};

const totalMinor = invoice.subtotalMinor + invoice.taxMinor; // 1082n

Percentage calculations create a ratio even when the stored values are integers. BigInt division truncates, so the application must name and implement the required rounding policy. This non-negative helper rounds half ties away from zero:

function divideHalfUpNonNegative(numerator, denominator) {
  if (numerator < 0n || denominator <= 0n) {
    throw new RangeError("Expected numerator >= 0 and denominator > 0");
  }

  const quotient = numerator / denominator;
  const remainder = numerator % denominator;
  return remainder * 2n >= denominator ? quotient + 1n : quotient;
}

const subtotalCents = 1999n;
const discountCents = divideHalfUpNonNegative(subtotalCents * 15n, 100n);
// 300n: 299.85 cents rounded once to 300 cents

Use Intl.NumberFormat for localized display. Its currency fraction digits and rounding options describe formatting; they do not turn the returned string into a storage format. The formatter accepts an exact decimal string, which avoids converting a large minor-unit BigInt through an unsafe Number.

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

const totalCents = 123456789123456789199n;
usd.format(`${totalCents}E-2`);
// "$1,234,567,891,234,567,891.99"
[ senior depth ]

Precision is relative to magnitude

ECMAScript Number arithmetic operates over IEEE 754 binary64 values. A finite Number has 53 bits of significant precision. Near 1, Number.EPSILON is the distance from 1 to the next larger representable Number. At larger magnitudes, adjacent representable values are farther apart.

Number.EPSILON;                 // 2.220446049250313e-16
Number.MAX_SAFE_INTEGER + 1;    // 9007199254740992
Number.MAX_SAFE_INTEGER + 2;    // 9007199254740992

The safe-integer limit follows from that precision. Every integer through 2 ** 53 - 1 has an exact, unique Number value. Above the limit, some mathematical integers map to the same Number. This is separate from overflow: finite Numbers extend to approximately 1.7976931348623157e308, with coarser spacing as their magnitude grows.

An interviewer may ask for an epsilon comparison. The missing question is, "epsilon for which data?" A useful comparison combines a small absolute tolerance near zero with a relative tolerance at larger magnitudes.

function approximatelyEqual(a, b, absoluteTolerance, relativeTolerance) {
  if (a === b) return true;
  if (!Number.isFinite(a) || !Number.isFinite(b)) return false;

  if (
    !Number.isFinite(absoluteTolerance) || absoluteTolerance < 0 ||
    !Number.isFinite(relativeTolerance) || relativeTolerance < 0
  ) {
    throw new RangeError("Tolerances must be finite and non-negative");
  }

  const difference = Math.abs(a - b);
  const scale = Math.max(Math.abs(a), Math.abs(b));
  return difference <= absoluteTolerance || difference / scale <= relativeTolerance;
}

The caller still owns both tolerances. Sensor resolution, accumulated operations, and the cost of a false match are domain facts. Number.EPSILON supplies none of them.

Wrong "Floating-point values should never be compared with ===."

Right Exact equality is correct when the contract requires the same Number value, including exact integer state inside the safe range. Approximate measurements need a domain-specific comparison.

NaN and Infinity require explicit boundary policy

The Number type includes NaN, positive Infinity, negative Infinity, positive zero, and negative zero. Strict equality returns false when either operand is NaN. Number.isNaN tests the value without coercion; Number.isFinite accepts only finite Number values.

function requireFiniteNumber(input) {
  if (typeof input !== "number" || !Number.isFinite(input)) {
    throw new TypeError("Expected a finite Number");
  }
  return input;
}

requireFiniteNumber(4.2);      // 4.2
requireFiniteNumber("4.2");    // TypeError
requireFiniteNumber(Infinity); // TypeError
requireFiniteNumber(NaN);      // TypeError

Infinity is a specified Number result for division by zero or overflow, but many business APIs cannot accept it. The boundary decides whether to reject, preserve, or map it. Silent serialization is unsafe as a policy: JSON stringification of non-finite Number values produces null in arrays and object properties.

JSON.stringify({ value: Infinity, error: NaN });
// '{"value":null,"error":null}'

Validate before serialization if null would hide a failed computation.

BigInt solves integer range, not decimal arithmetic

BigInt values represent arbitrary-size integers. They are useful when the integer range or exactness requirement exceeds Number's safe range. Construct them from original integer text when the source may be unsafe.

const correct = BigInt("9007199254740993"); // 9007199254740993n

const alreadyRounded = 9007199254740993;    // Number 9007199254740992
BigInt(alreadyRounded);                     // 9007199254740992n

Most arithmetic rejects mixed Number and BigInt operands. BigInt division truncates toward zero, and BigInt has no Infinity. Division by 0n throws a RangeError.

5n / 2n; // 2n
1n + 1;  // TypeError
1n / 0n; // RangeError

Ordinary JSON.stringify() also throws on a BigInt unless the application supplies a serialization strategy. Decimal strings are a portable choice when both sides agree that the field contains an integer.

const wire = JSON.stringify({ amountMinor: 12345678901234567890n.toString() });
const parsed = BigInt(JSON.parse(wire).amountMinor);

A money pipeline has two scales

There is a storage scale, such as cents for a particular USD contract, and a computation scale created by taxes, discounts, exchange rates, or allocations. Undocumented intermediate rounding can change the result. Keep an exact integer ratio until the contract requires rounding, and apply the named policy at every boundary the contract defines.

This helper implements half-even rounding for a non-negative rational value. On an exact half tie, it chooses the even quotient. The policy matches the halfEven name exposed by Intl.NumberFormat, but this code performs the stored integer calculation rather than display formatting.

function divideHalfEvenNonNegative(numerator, denominator) {
  if (numerator < 0n || denominator <= 0n) {
    throw new RangeError("Expected numerator >= 0 and denominator > 0");
  }

  const quotient = numerator / denominator;
  const remainder = numerator % denominator;
  const doubled = remainder * 2n;

  if (doubled < denominator) return quotient;
  if (doubled > denominator) return quotient + 1n;
  return quotient % 2n === 0n ? quotient : quotient + 1n;
}

divideHalfEvenNonNegative(245n, 10n); // 24n: tie goes to even 24
divideHalfEvenNonNegative(255n, 10n); // 26n: tie goes to even 26

Parsing must avoid a lossy Number detour. A decimal parser can convert text digits into a scaled integer, reject excess precision, and keep the currency beside the amount. If the product accepts more fractional digits than the storage scale, the parser needs the same explicit rounding policy rather than an implicit Math.round(value * 100).

function parseFixedScale(text, scale) {
  if (!Number.isSafeInteger(scale) || scale < 0) {
    throw new RangeError("Scale must be a non-negative safe integer");
  }

  const match = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(text);
  if (!match) throw new RangeError("Invalid decimal amount");

  const fraction = match[3] ?? "";
  if (fraction.length > scale) {
    throw new RangeError("Amount exceeds the accepted scale");
  }

  const sign = match[1] === "-" ? -1n : 1n;
  const units = BigInt(match[2]) * 10n ** BigInt(scale);
  const minor = BigInt(fraction.padEnd(scale, "0") || "0");
  return sign * (units + minor);
}

parseFixedScale("-19.90", 2); // -1990n

For display, Intl.NumberFormat applies locale-sensitive currency conventions. Currency formatting has currency-specific default fraction digits. Current ECMA-402 also defines roundingMode, which can make the display policy explicit. Display settings do not replace the storage contract.

const formatter = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
  roundingMode: "halfEven",
});

function formatMinorUnits(formatter, minorUnits, scale) {
  if (typeof minorUnits !== "bigint") {
    throw new TypeError("Minor units must be a BigInt");
  }
  if (!Number.isSafeInteger(scale) || scale < 0) {
    throw new RangeError("Scale must be a non-negative safe integer");
  }
  return formatter.format(`${minorUnits}E-${scale}`);
}

formatMinorUnits(formatter, 1990n, 2); // "$19.90"

Current Intl.NumberFormat.format() uses the exact value represented by a string input. Older versions of ECMA-402 parsed strings as Number, and older runtimes may also lack roundingMode, so supported runtimes need compatibility tests or another exact formatting path.

An amount model therefore carries at least a currency and exact units, plus a documented scale and rounding policy at the service boundary. A bare floating-point field leaves those decisions implicit.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more JavaScript

was this useful?