Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope

What will I learn
- You will learn how JavaScript stores every number as a single 64-bit floating point value, and what that implies;
- exactly why
0.1 + 0.2produces0.30000000000000004, in plain language and with the actual stored bits shown; - what
NaNandInfinityare, and how to detect them safely; - the safe-integer limit, and when you need
bigintin stead; - practical patterns for money, rounding, formatting, and comparing decimals without getting burned;
- how JavaScript's number model compares to Python, Rust, Go and C, so the design choices make sense.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Node.js (20+) distribution, or just a modern browser console;
- Episodes 1-5 read, so types, operators and strings are familiar.
Difficulty
- Beginner
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope (this post)
Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
Solutions to Episode 5 Exercises
As always, we open with worked solutions to last episode's three exercises. Type them out, run them, and compare against your own attempts -- the small gaps are exactly where the understanding sharpens.
Exercise 1 - clean up a messy string in one chain:
const cleaned = " Scipio_NL ".trim().toLowerCase().replaceAll("_", " ");
console.log(cleaned); // "scipio nl"
The insight: because each string method returns a new string, you can chain them, each one operating on the result of the previous. Order matters: trim first, then lowercase, then swap the underscore. You had to capture the final return value because strings are immutable -- the original " Scipio_NL " is never touched.
Exercise 2 - splitting a date:
const line = "2026-08-13";
const [year, month, day] = line.split("-");
console.log(`Year: ${year}`);
console.log(`Month: ${month}`);
console.log(`Day: ${day}`);
The insight: split("-") gives an array ["2026", "08", "13"], and array destructuring (which we cover deeply later) names the three pieces in one line, each then dropped into its own template literal.
Exercise 3 - building initials:
function initials(fullName) {
return fullName
.split(" ")
.map((part) => part[0].toUpperCase())
.join(".") + ".";
}
console.log(initials("scipio the great")); // "S.T.G."
The insight: we split into words, take and uppercase the first character of each, then join with dots. Every step returns a new value; the original fullName is never mutated, because strings cannot be.
Right. Now the episode people either love or fear: numbers. There is one famous quirk that trips up every newcomer, a small handful of special values that behave strangely, and a couple of professional habits that make all of it painless. By the end you will understand not just what happens but why, which is the difference between memorising a workaround and actually knowing your tools.
One type, and it is a float
As we saw in episode 3, JavaScript has a single number type, and it is always a 64-bit IEEE 754 double-precision floating point value. That is the exact same format C and Java call double. There is no separate integer type at all (until you reach for bigint, which we met briefly and return to at the end). So 5 and 5.0 are literally the same value, bit for bit:
console.log(5 === 5.0); // true - identical
console.log(Number.isInteger(5)); // true
console.log(Number.isInteger(5.5)); // false
console.log(typeof 5, typeof 5.0); // "number" "number"
What does "floating point" actually mean? A double stores a number in three parts: a sign bit, an 11-bit exponent, and a 52-bit fraction (the "mantissa"). Think of scientific notation, but in binary: a value is roughly mantissa x 2^exponent. That layout is what lets a single 64-bit value represent everything from 0.0000001 to numbers with hundreds of digits. The price for that enormous range is that only a fixed number of binary digits are available for the fraction. And that limitation is the root of the famous problem.
Why 0.1 + 0.2 is not 0.3
Here is the notorious result, which happens in Java, C, Python, Ruby, and basically every language using IEEE 754, not just JavaScript:
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
The reason is beautifully simple once you see it. Computers store numbers in binary (base 2). In base 10, we cannot write 1/3 exactly -- it is 0.3333... forever, and if you only have room for, say, ten digits you have to stop and accept a tiny error. In base 2, the exact same thing happens with 0.1: one tenth simply cannot be written as a finite binary fraction. So 0.1 is stored as the closest binary value it can manage, which is very slightly off. Same for 0.2. Add two slightly-off values together and the tiny errors accumulate into that trailing ...04.
Do not take my word for it -- you can ask JavaScript to show you the real stored values with toPrecision, which prints a given number of significant digits:
console.log((0.1).toPrecision(20)); // "0.10000000000000000555"
console.log((0.2).toPrecision(20)); // "0.20000000000000001110"
console.log((0.3).toPrecision(20)); // "0.29999999999999998890"
Look at that. The number you typed as 0.1 is not stored as one tenth at all -- it is stored as 0.10000000000000000555.... When you add the stored 0.1 and the stored 0.2, the result rounds to a value that is a hair above the stored 0.3, so the === comparison fails. Normally JavaScript hides these extra digits and prints a friendly 0.1, but the moment arithmetic pushes the error into the visible range, the mask slips.
It is not a bug and it is not JavaScript being sloppy. It is a fundamental consequence of representing decimal fractions in binary with limited digits. Every language that uses hardware floats has this exact behaviour. Knowing that is half the battle -- the other half is knowing the handful of coping strategies below.
How to cope: comparing decimals
Because decimals can be slightly off, you must never compare the results of decimal math with ===. Instead, check whether two values are close enough, within a tiny tolerance called an epsilon:
function almostEqual(a, b, epsilon = Number.EPSILON) {
return Math.abs(a - b) < epsilon;
}
console.log(almostEqual(0.1 + 0.2, 0.3)); // true
Number.EPSILON is the smallest meaningful difference between numbers near 1, and Math.abs gives the absolute (always positive) distance between the two values. If that distance is smaller than epsilon, treat them as equal. This is the standard technique in every language with floats.
There is one subtlety worth knowing early. A fixed epsilon like Number.EPSILON works fine for numbers near 1, but floating point error grows with the size of the number -- the gaps between representable values get wider as values get bigger. So for a robust comparison you scale the tolerance by the magnitude of the operands:
function almostEqualScaled(a, b, epsilon = Number.EPSILON) {
return Math.abs(a - b) <= epsilon * Math.max(1, Math.abs(a), Math.abs(b));
}
console.log(almostEqualScaled(0.1 + 0.2, 0.3)); // true
console.log(almostEqualScaled(1000000.1 + 0.2, 1000000.3)); // true - scales up
You do not need to reach for the scaled version every day, but it is good to understand why a plain fixed epsilon can miss for large numbers. When in doubt for small everyday values, the simple almostEqual is perfectly serviceable.
How to cope: money
For money, the classic professional advice is blunt: do not store amounts as decimal dollars or euros. Store them as whole cents (integers), do your arithmetic in integers, and only divide by 100 for display:
const priceCents = 1999; // 19.99 EUR stored as cents
const qty = 3;
const totalCents = priceCents * qty; // 5997, exact integer math
console.log(`Total: ${(totalCents / 100).toFixed(2)} EUR`); // "Total: 59.97 EUR"
Integer math is exact (up to the safe-integer limit we cover below), so working in cents sidesteps the whole floating-point mess for currency. You compute the entire invoice in integer cents and only convert to a decimal string at the very last moment, when you show it to a human. .toFixed(2) formats a number with exactly two decimals as a string, perfect for display.
Why does this matter so much? Because money bugs are the kind that get you an angry email. If you sum a thousand prices as raw floats, those microscopic errors can accumulate until a total is off by a cent, and an accountant will notice a total that does not reconcile. Integers never drift. This is not a JavaScript-specific trick either -- keeping money in the smallest currency unit is standard practice across the whole industry, in every language.
Rounding, and the toFixed surprise
JavaScript gives you a family of rounding tools on the Math object:
console.log(Math.round(2.5)); // 3 - nearest, ties round up
console.log(Math.floor(2.9)); // 2 - always down
console.log(Math.ceil(2.1)); // 3 - always up
console.log(Math.trunc(-2.7)); // -2 - just drop the fraction, toward zero
There is a trap hiding in Math.round with negative numbers. It does not round "away from zero" on ties -- it always rounds toward positive infinity:
console.log(Math.round(2.5)); // 3
console.log(Math.round(-2.5)); // -2, NOT -3 - ties go toward +Infinity
So -2.5 rounds to -2, not -3, which surprises people who assume ties always move away from zero. Keep it in mind whenever negatives are in play.
Now watch out that .toFixed returns a string, not a number, and occasionally rounds in genuinely surprising ways because of the underlying float:
const rounded = (3.14159).toFixed(2);
console.log(rounded); // "3.14"
console.log(typeof rounded); // "string" - not a number!
console.log(Number(rounded)); // 3.14 - convert back if you need math
And the classic gotcha that catches everyone at least once:
console.log((1.005).toFixed(2)); // "1.00" - surprise, not "1.01"!
console.log((1.005).toPrecision(20)); // "1.0049999999999998934" - there's why
You would swear 1.005 rounded to two decimals should be 1.01. But 1.005 cannot be stored exactly either -- the nearest double is 1.00499999..., which is below 1.005, so it correctly rounds down to 1.00. This is the same IEEE 754 story from the top of the episode, just wearing a different hat. So if you .toFixed() and then try to do arithmetic, remember to convert back with Number() first, or you will accidentally concatenate strings in stead of adding numbers.
Formatting numbers for humans
When you actually show numbers to users, .toFixed is often not enough -- you want thousands separators, currency symbols, percentages, all correct for the reader's locale. The built-in Intl.NumberFormat handles this properly, and it is criminally underused:
const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" });
console.log(eur.format(1234.5)); // "EUR1,234.50" (with the euro symbol on screen)
const plain = new Intl.NumberFormat("en-US");
console.log(plain.format(1234567.89)); // "1,234,567.89"
const pct = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1 });
console.log(pct.format(0.734)); // "73.4%"
Reach for Intl.NumberFormat whenever a number is going in front of a person. It rounds, groups digits, and places currency symbols the way each locale expects, so you do not hand-roll any of that yourself. For machine-to-machine values (writing to a file, sending over the network), keep the raw number and only format at the display edge -- exactly the same "compute in the pure form, format last" discipline we used for money.
NaN and Infinity
Some operations produce special number values. Dividing by zero gives Infinity (not an error, unlike many languages), and nonsensical math gives NaN, which stands for Not a Number:
console.log(1 / 0); // Infinity
console.log(-1 / 0); // -Infinity
console.log(0 / 0); // NaN
console.log(Number("hello")); // NaN
console.log(Math.sqrt(-1)); // NaN
NaN has a bizarre and important property: it is not equal to anything, including itself:
console.log(NaN === NaN); // false - the only value in the language not equal to itself!
That looks insane, but it follows from what NaN means: "the result of a calculation that has no meaningful numeric answer". Two separate failed calculations are not somehow "the same failure", so the standard declares NaN unequal to everything. The practical fallout is that you cannot test for NaN with ===. Use Number.isNaN() in stead, which is the reliable check:
const result = Number("oops");
console.log(Number.isNaN(result)); // true - the correct way to detect NaN
console.log(Number.isFinite(42)); // true - not Infinity, not NaN
console.log(Number.isFinite(1/0)); // false - Infinity is not finite
Always prefer Number.isNaN and Number.isFinite (the ones attached to Number) over the old global isNaN and isFinite. The global versions coerce their argument first, so isNaN("hello") is true even though a string is obviously not the special NaN value -- a subtle bug factory. The Number. versions do no coercion and answer the question you actually asked.
The safe integer limit
Because numbers are 64-bit floats with only 52 bits of fraction, only integers up to a certain size are represented exactly. Beyond that, you silently lose precision:
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991
console.log(9007199254740991 + 1); // 9007199254740992 - still fine
console.log(9007199254740991 + 2); // 9007199254740992 - WRONG, lost the 1
console.log(Number.isSafeInteger(2 ** 53)); // false
Above roughly nine quadrillion (2 to the 53rd power), consecutive integers can no longer all be stored, so x + 1 and x + 2 can collapse to the same value. If you genuinely need huge whole numbers -- large database IDs, cryptographic values, nanosecond timestamps, big counters -- that is exactly what bigint from episode 3 is for, since it has no size limit:
const huge = 9007199254740991n; // the trailing n makes it a bigint
console.log(huge + 1n); // 9007199254740992n - exact
console.log(huge + 2n); // 9007199254740993n - exact, no precision loss
For everyday counting you will never come close to MAX_SAFE_INTEGER, but knowing it exists prevents a genuinely nasty class of bug -- the kind where a giant ID from a server arrives subtly wrong and you spend an afternoon wondering why two "different" records keep colliding. Note you cannot freely mix bigint and number in arithmetic; you convert deliberately, which we touched on back in episode 3.
How this compares to Python, Rust, Go and C
Many of you arrived from the Learn Python Series, so a glance sideways sharpens the picture. The single most important thing to internalise is that the floating-point behaviour is not JavaScript's fault -- it is the hardware, and every one of these languages inherits it.
Python uses the same IEEE 754 doubles for its float type, so the famous bug is character-for-character identical. Where Python differs is that it has a real separate integer type (int) with arbitrary precision built in, plus a decimal.Decimal type in the standard library for exact decimal math when you need it:
# Python: floats are the SAME IEEE 754 doubles, so the bug is identical
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2")) # 0.3 - exact, via a decimal type
So a Python programmer reaching for money-safe math often uses Decimal where a JavaScript programmer works in integer cents. Different tool, same goal: keep exactness out of binary floats.
Rust makes the number model explicit in the type system. It has f64 (the same double, with the same 0.1 + 0.2 result) but also a whole family of distinct integer types -- i32, u64, and so on -- that are genuinely exact and cannot silently turn into floats. You choose the type, and the compiler holds you to it:
// Rust: f64 is the same double; integers are SEPARATE, exact types
fn main() {
println!("{}", 0.1_f64 + 0.2); // 0.30000000000000004
let big: u64 = 9_007_199_254_740_993; // exact - no float involved
println!("{}", big);
}
That u64 value is the very number JavaScript could not represent exactly, and Rust stores it perfectly because it is a true 64-bit integer, not a float pretending to be one.
Go takes the same "separate, honest types" stance: float64 is the identical IEEE 754 double, while int and int64 are exact integers you pick deliberately:
// Go: float64 is the same double; int is separate and exact
package main
import "fmt"
func main() {
fmt.Println(0.1 + 0.2) // 0.30000000000000004
var big int64 = 9007199254740993 // exact in a 64-bit integer
fmt.Println(big)
}
C is where all of this comes from -- double in C is the IEEE 754 format that JavaScript adopted wholesale, and C has had int, long, long long and the rest since forever. C gives you the most control and the least safety: overflow behaviour, sizes, and signedness are all your problem.
So where does JavaScript land? It made a radical simplification back in the 1990s: one number type, the double, for everything. That is wonderful for beginners -- there is only one numeric type to learn, no choosing between int and float, no overflow surprises for small values. The cost is precisely the two rough edges we spent this episode on: decimal math is inexact, and integers above nine quadrillion lose precision. bigint was added years later to patch the second hole. Knowing that JavaScript deliberately traded a family of exact integer types for one-simple-number is exactly what makes its quirks feel like understandable engineering choices rather than random madness ;-)
Putting it together
Let us close with a small program that leans on several of today's tools at once. We will price a shopping cart the professional way -- everything in integer cents, formatted for a human only at the end:
function invoiceTotal(items) {
// each item is { priceCents, qty }; we never leave integer-land until display
const totalCents = items.reduce((sum, it) => sum + it.priceCents * it.qty, 0);
const euros = totalCents / 100;
const fmt = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" });
return fmt.format(euros);
}
const cart = [
{ priceCents: 1999, qty: 3 }, // 3 x 19.99
{ priceCents: 495, qty: 7 }, // 7 x 4.95
];
console.log(invoiceTotal(cart)); // "EUR94.62" (euro symbol on screen)
Trace it through. Each line multiplies an integer price by an integer quantity, so 1999 * 3 is exactly 5997 and 495 * 7 is exactly 3465. reduce (an array tool we cover properly later) just adds those exact integers into 9462 cents. Only at the final step do we divide by 100 and hand the value to Intl.NumberFormat, which groups the digits and attaches the currency symbol. At no point did a decimal float participate in the arithmetic, so there is no room for a rounding error to creep in. That is the entire money discipline in nine lines.
Try it yourself
- Write the
almostEqualfunction from this episode and use it to confirm that0.1 + 0.2is "equal" to0.3within a small epsilon, while0.1 + 0.2 === 0.3isfalse. Print both results, and add a comment explaining in your own words why the===version fails. - Model a shopping cart: an item costs
4.95EUR and the customer buys7. Compute the exact total by working in cents (integers), then print it formatted to two decimals. Verify you got34.65, and explain why doing the same sum with4.95 * 7as plain floats is risky. - Write a function
isRealNumber(x)that returnstrueonly ifxis a finite number (notNaN, notInfinity). Test it with42,NaN,Infinity, andNumber("abc"), and explain in a comment why you cannot usex === NaNinside it.
So what did we actually cover?
- Every JavaScript number is a 64-bit IEEE 754 float;
5and5.0are the same value, and there is no separate integer type untilbigint. 0.1 + 0.2 !== 0.3because one tenth has no finite binary representation -- a property of all IEEE 754 languages, not a JS bug -- andtoPrecision(20)reveals the real stored values.- Compare decimals with an epsilon tolerance (
Math.abs(a - b) < Number.EPSILON), never with===, and scale the epsilon by magnitude for large numbers. - For money, store and compute in integer cents, then divide by 100 and format only at the display edge; remember
toFixedreturns a string and(1.005).toFixed(2)is"1.00". - Use
Intl.NumberFormatto show numbers to humans (separators, currency, percent); keep raw numbers for machines. 1/0isInfinity, bad math isNaN, andNaN !== NaN, so detect it withNumber.isNaNand useNumber.isFinitefor "is this a real number" -- always theNumber.versions, never the coercing globals.- Integers are exact only up to
Number.MAX_SAFE_INTEGER(about 9 quadrillion); beyond that, reach forbigint. - Python shares the same float bug but has real
intandDecimal; Rust, Go and C all expose separate exact integer types; JavaScript deliberately chose one-number-for-everything for simplicity.
Next episode we move from data to decisions: control flow with if/else, the switch statement, and the ternary expression we have been leaning on in the solutions.