Before you begin
Why this matters
Every TypeScript number is a floating-point number. Decimals in your code look exact, but most of them are stored as a nearby binary value. Results then end in …9999 or …0004, a loop can miss its end value, and a price can lose a cent, all without an error.
What this helps you reason about
- Predict when a sum of decimals is exact and when it drifts.
- Explain where Infinity and NaN come from and what they do to later results.
- Round at the right moment, and keep money exact in whole cents.
A tram fare of 0.10 added ten times should make 1.00. In TypeScript it makes 0.9999999999999999. This lesson shows what a number really stores, why such results appear, and how to round, compare, and count money so they do not reach your users.
Already know this? The short version
- number is an IEEE 754 double: 53 binary digits, about 15 to 17 significant decimal digits. 0.1 is stored as the nearest binary fraction, and printing shows the shortest decimal that reads back the same.
- Each operation rounds, so errors add up (ten 0.1s make 0.9999999999999999), and precision is relative: 1e16 + 1 === 1e16, and whole numbers are exact only up to 2^53.
- toFixed rounds the text, not the value. Keep money in whole cents, converted once with Math.round(amount * 100).
- 1 / 0 is Infinity and 0 / 0 is NaN, with no error; NaN spreads, and every comparison with it is false. Test with Number.isNaN and Number.isFinite.
- Math.trunc cuts toward zero, so Math.trunc(0.29 * 100) is 28; use Math.round for a value that should be whole.
Words in this lesson (7)
- floating-point number
- A number stored in binary, with about 16 significant decimal digits. Most decimal fractions, such as 0.1, are stored as the nearest binary value, not exactly.
- significant digits
- The digits of a number from its first non-zero digit on. 0.0123 has three significant digits, and 120.5 has four.
- rounding error
- The difference between the exact result of a calculation and the floating-point value stored for it.
- safe integer
- A whole number from -(2^53 - 1) to 2^53 - 1. A TypeScript number holds each of them exactly; beyond them, some whole numbers cannot be stored.
- infinity
- A floating-point value larger than every other number; minus infinity is smaller than every other number. A result too large to store becomes infinity.
- NaN
- “Not a number”: the floating-point value that results from a calculation with no answer, such as 0 / 0. NaN is not equal to anything, not even itself.
- truncation
- Cutting off the fraction of a number, toward zero: 2.9 becomes 2, and -2.9 becomes -2.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / Stored value
What does a floating-point number store?
Bits, bytes, and binary showed whole numbers in base 2. A fraction can be written in base 2 too. After the point, the places are worth one half, one quarter, one eighth, and so on. So 0.75 is 0.11 in base 2: one half plus one quarter. 0.375 is 0.011: one quarter plus one eighth.
A floating-point number stores a binary fraction like this, with 53 binary digits, and an exponent: a second number that says where the point goes. The point can move, or “float”, so the same 53 digits can hold 0.000123 or 123,000,000. 53 binary digits are worth about 15 to 17 significant digits in decimal. Significant digits are the digits from the first non-zero digit on: 0.0123 has three. In TypeScript, every number is a floating-point number of this kind, 64 bits in all.
0.75 and 0.375 are sums of halves, quarters, and eighths, so they are stored exactly. 0.1 is not. One tenth in base 2 is 0.000110011001100…, and the digits never end, just as one third in decimal is 0.333…. So the value stored for 0.1 is the nearest binary fraction with 53 digits, which is slightly more than 0.1.
When TypeScript prints a number, it writes the shortest decimal that leads back to the same stored value. That is why 0.1 prints as 0.1. toFixed(20) asks for 20 decimal places and shows more of the stored value: 0.10000000000000000555.
console.log(0.1); // 0.1
console.log((0.1).toFixed(20)); // 0.10000000000000000555
console.log((0.375).toFixed(20)); // 0.37500000000000000000 A floating-point number holds a binary fraction with about 16 significant digits; most decimal fractions are stored as the nearest one.
Precisely: the 64 bits
The 64 bits are one sign bit, 11 bits of exponent, and 52 bits of fraction. For every number except 0 and the very smallest ones, the first binary digit is 1, so it is not stored, which gives 53 digits. This is the binary64 format of the IEEE 754 standard. Any decimal with 15 significant digits comes back unchanged after a trip into this format and out again, and 17 significant digits always tell two stored values apart. A Float32Array stores 32-bit floating-point numbers, with 24 binary digits, about 7 significant digits.
02 / Drift
Why does a sum of decimals drift?
Each arithmetic operation on floating-point numbers works out the exact result and then rounds it to the nearest value that can be stored. The difference between the two is a rounding error. One rounding error is tiny, at most about one part in 10^16 of the result. But each step of a long calculation can add one, and they do not cancel out.
Equality and comparison showed one sum: 0.1 + 0.2 prints as 0.30000000000000004. Adding 0.1 ten times shows the errors collecting. The total passes through 0.30000000000000004 and 0.7999999999999999 and ends at 0.9999999999999999, not 1. The loop below runs its block 10 times: for (let i = 0; i < 10; i++) starts i at 0, runs while i < 10 is true, and adds 1 to i after each round.
The errors are small next to the numbers, so a result printed with a few decimal places looks right. They matter when the program compares a result with ==, or cuts it to a whole number. Numbers that are sums of halves, quarters, and eighths, such as 0.25, add up exactly.
let total = 0;
for (let i = 0; i < 10; i++) {
total = total + 0.1;
}
console.log(total); // 0.9999999999999999 Every operation rounds its result, so the rounding errors of a long calculation add up.
Coming from Python: TypeScript has no separate integer type. 3 and 3.0 are the same number, and both are floating-point numbers.
Experiment
Add 0.1 ten times
Each + works out the exact sum and rounds it to the nearest value that can be stored. 0.1 is stored slightly above one tenth, and each new total is rounded again, sometimes up and sometimes down. After ten rounds, the errors leave the total just below 1. 0.25 is one quarter, a binary fraction, so every total is stored exactly.
Why it matters A total built from decimal steps is close to the exact answer, not equal to it. Do not test it with ==.
Predict the last value of total. Then change 0.1 to 0.25 in the loop and predict again.
let total = 0;
for (let i = 0; i < 10; i++) {
total = total + 0.1;
}
console.log(total);
What to look for
total goes 0.1, 0.2, 0.30000000000000004, … 0.7999999999999999, 0.8999999999999999, and the run prints 0.9999999999999999. After the edit, every total is exact, and the run prints 2.5. The stepper stops on the for line twice in each round, and it shows a name that has no value yet as not initialized yet.
Expected output
0.999999999999999903 / Size
Why can adding a small number change nothing?
The 53 binary digits are counted from the first non-zero digit, so precision is relative to the size of a number. Near 1, two neighbouring stored values are about 2 × 10^-16 apart. Near 10^16, they are 2 apart. A result between two neighbours is rounded to one of them.
So adding 1 to 1e16 changes nothing. 1e16 means 1 × 10^16, and the exact sum, 10,000,000,000,000,001, lies halfway between the stored values 10^16 and 10^16 + 2. It is rounded back to 10^16. A small number added to a large one can disappear completely. That makes the order of a sum matter: add the small amounts together first, then add the large one.
Whole numbers have the same limit. Every whole number up to 2^53, which is 9,007,199,254,740,992, has its own stored value. Above it, only every second whole number has one, then every fourth. Integers, signs, and overflow showed what this means in TypeScript, where every number is a floating-point number: whole numbers are safe integers only up to 2^53 - 1, and 2 ** 53 + 1 is 2 ** 53.
const big = 1e16;
console.log(big + 1); // 10000000000000000
console.log(big + 1 === big); // true Precision is relative to size: next to a large number, a small one can disappear.
Precisely: summing many numbers
Adding 1 four times to 1e16 leaves 1e16, but adding the four 1s first and then 1e16 gives 10000000000000004. To add many numbers of very different sizes, add the small ones first, or use a method that carries the lost part forward, such as Kahan summation.
04 / Rounding
How do you round for display, and how do you keep money exact?
Rounding for display changes the text, not the value. Printing values and reading input used toFixed: when total is 0.1 + 0.2, total.toFixed(2) is the string "0.30". total is still 0.30000000000000004 afterwards; only the text says 0.30. So round for display at the last moment, when you print, and keep the full value for any further arithmetic.
To round the value itself to cents, multiply by 100 and round to the nearest whole number: Math.round(total * 100) is 30, a whole number of cents.
Money needs exact sums. Rounding errors that collect over many steps can reach half a cent, where rounding to cents goes the wrong way, and a total compared with == can fail. So store money as a whole number of its smallest unit, such as cents. Integers add, subtract, and compare exactly, as long as they stay in their range. Convert a typed amount such as 2.20 to cents once, rounding to the nearest cent, and turn cents back into text with two decimal places only when you print. In TypeScript, a count of cents is exact up to 2^53, far beyond any price.
const total = 0.1 + 0.2;
console.log(total.toFixed(2)); // 0.30, as text
console.log(total); // 0.30000000000000004
const cents = 10 + 20; // 30, exact
console.log((cents / 100).toFixed(2)); // 0.30 Round for display only when you print, and keep money as a whole number of cents.
Coming from Java: There is no BigDecimal in the language. Keep money in a number of whole cents, or use a decimal library.
Precisely: halves in toFixed
toFixed rounds the stored value. (2.675).toFixed(2) is "2.67", because the value stored for 2.675 is slightly less than 2.675. A value that is exactly half, such as 0.125, rounds up: (0.125).toFixed(2) is "0.13".
05 / Infinity and NaN
Where do infinity and NaN come from?
Besides ordinary numbers, a floating-point type has special values. Infinity is larger than every other number, and minus infinity is smaller than every other number. A result too large to store becomes infinity: 1e308 * 10 gives Infinity. Dividing a number other than 0 by 0 also gives Infinity or -Infinity, not an error.
NaN, “not a number”, is the result of a calculation with no answer, such as zero divided by zero, or infinity minus infinity. Equality and comparison showed that NaN is not equal to anything, not even itself. Two more rules make NaN hard to notice. First, NaN spreads: arithmetic with NaN gives NaN, so one missing value turns a whole total into NaN. Second, every comparison with NaN is false: for a NaN x, x > 1 is false, and x <= 1 is false too.
Neither value stops the program. Test for them where they can arise: Number.isNaN(x) is true only for NaN, and Number.isFinite(x) is true only for an ordinary number.
console.log(1e308 * 10); // Infinity
console.log(1 / 0); // Infinity
const missing = 0 / 0; // NaN
console.log(missing + 1); // NaN
console.log(missing > 1, missing <= 1); // false false
console.log(Number.isNaN(missing)); // true Infinity and NaN never stop a program: NaN spreads through arithmetic, and every comparison with it is false.
Coming from Java: Integer division by 0 throws in Java. In TypeScript every number is a floating-point number, so 1 / 0 is Infinity and nothing is thrown.
Precisely: isNaN and Number.isNaN
The older global isNaN converts its argument to a number first, so isNaN("fare") is true. Number.isNaN converts nothing and is true only for the value NaN. JSON has no Infinity or NaN: JSON.stringify writes both as null.
Experiment
Divide 0 by 0, then keep going
0 divided by 0 has no answer, so the result is NaN, and NaN + 0.5 is NaN too. Every comparison with NaN is false, so withFee > 1 and withFee <= 1 are both false. 7 divided by 0 is too large for any number, so it gives infinity, which is larger than 1.
Why it matters Nothing stops the program, so a division by a count that can be 0 needs a check before it. Test a result for NaN before you compare it.
Predict the four printed values. Then change const fares = 0; to const fares = 7; and predict again.
const fares = 0;
const rides = 0;
const average = fares / rides;
const withFee = average + 0.5;
console.log(average, withFee, withFee > 1, withFee <= 1);
What to look for
average shows NaN after line 3, and withFee shows NaN after line 4. The run prints NaN NaN false false. After the edit, both show Infinity, and the run prints Infinity Infinity true false.
Expected output
NaN NaN false false06 / To an integer
What happens when a number is cut to a whole number?
Converting a floating-point number to an integer keeps only the whole part. This is truncation: cutting off the fraction, toward zero, so 2.9 becomes 2, and -2.9 becomes -2. Math.trunc(x) truncates. The result is still a number, but a whole one.
Together with a rounding error, truncation can lose a whole unit. 0.29 * 100 is 28.999999999999996, just below 29, and truncation gives 28. When a result should be a whole number but comes from floating-point arithmetic, round it to the nearest whole number instead. Math.round(x) gives 29. It rounds an exact half up: Math.round(2.5) is 3, and Math.round(-2.5) is -2.
Math.trunc and Math.round leave Infinity and NaN as they are; check with Number.isFinite first. Inference, coercion, and conversion shows the other conversions between number types, and what each one can lose.
console.log(Math.trunc(2.9), Math.trunc(-2.9)); // 2 -2
const cents = 0.29 * 100; // 28.999999999999996
console.log(Math.trunc(cents)); // 28
console.log(Math.round(cents)); // 29 Converting to an integer cuts toward zero; round first when the value comes from arithmetic.
Precisely: parseInt and | 0
parseInt(x) and x | 0 also truncate, and both can surprise. parseInt converts x to text first, so parseInt(1e21) is 1, from the text "1e+21". x | 0 works on 32-bit integers, so a value of 2^31 or more comes back wrong. Math.trunc and Math.round have neither problem.
Experiment
Turn 0.29 into whole cents
0.29 is stored slightly below 0.29, so the product is just below 29. Truncation cuts off the fraction, .999999999999996, and leaves 28. Rounding goes to the nearest whole number, 29.
Why it matters Never truncate a value that comes from floating-point arithmetic. Round it to the nearest whole number, or keep it as an integer from the start.
Predict cents and whole. Then change Math.trunc(cents) to Math.round(cents) and predict again.
const price = 0.29;
const cents = price * 100;
const whole = Math.trunc(cents);
console.log(cents, whole);
What to look for
cents shows 28.999999999999996, and whole shows 28. The run prints 28.999999999999996 28. After the edit, whole shows 29, and the run prints 28.999999999999996 29.
Expected output
28.999999999999996 2807 / Try it
Can you explain a fresh example?
Work out each expression from left to right, and round after every step. The next lesson, Conditions and truthiness, uses comparisons to choose what a program does; remember that every comparison with NaN is false.
const total = 1e16;
console.log(total + 1 + 1 - total, 1 + 1 + total - total);
What is printed?
Reveal the reasoning
0 2. Each expression is worked out from the left. In the first, total + 1 is rounded back to 1e16, and so is the next + 1, so subtracting total gives 0. In the second, 1 + 1 is 2 first, and 1e16 + 2 is a stored value, so the 2 survives.
Practice tickets
- FLOAT-101 · Some weekly courier payments are one cent short → Free
- FLOAT-102 · The charger keeps going past the energy the driver chose → Sign in · Pro
- FLOAT-103 · Some fuel sales are charged a cent less than the pump rule says → Sign in · Pro