JavaScript Numbers

Unlike many languages, JavaScript has just one primitive numeric type for ordinary math: number. Whether you write 42, -7, or 3.14159, it’s all the same underlying type, stored the same way in memory. Understanding how that storage works — and where it breaks down — is essential, because it explains some of JavaScript’s most notorious “gotchas” like 0.1 + 0.2 not equaling 0.3.

Overview: How Numbers Work in JavaScript

Every JavaScript number is stored as a 64-bit IEEE-754 double-precision floating-point value. This single format is used for both integers and decimals — there is no separate int or float type. Internally, those 64 bits are split into three parts: 1 bit for the sign, 11 bits for the exponent, and 52 bits for the mantissa (the significant digits). That gives JavaScript about 15–17 significant decimal digits of precision.

Because the format is binary, it can represent powers of two exactly but cannot always represent decimal fractions exactly — the same way 1/3 can’t be written exactly in base 10. This is why 0.1 + 0.2 produces a tiny rounding error instead of a clean 0.3. It’s not a JavaScript bug; it’s how virtually every mainstream language that uses IEEE-754 floats (Python, Java, C, Go) behaves.

Integers are special-cased: JavaScript can represent whole numbers exactly up to Number.MAX_SAFE_INTEGER (253 − 1, or 9007199254740991). Beyond that, the 52-bit mantissa runs out of room, and some integers can no longer be distinguished from their neighbors. For numbers larger than that, JavaScript offers a second, separate numeric type: BigInt, written with a trailing n (e.g. 10n), which stores arbitrarily large integers exactly but cannot be mixed with regular numbers in the same expression.

There’s also a related but distinct Number object — a wrapper you rarely construct directly (avoid new Number(5)). When you call a method like (5).toFixed(2) on a primitive number, JavaScript briefly “boxes” the primitive into a temporary Number object, runs the method, and discards the wrapper. You get the convenience of methods without paying for object overhead in everyday use.

Syntax

Number literals can be written in several forms:

Form Example Description
Decimal integer 255 Base-10 whole number
Decimal float 3.14 Contains a fractional part
Exponential 2.5e3 2.5 × 103 = 2500
Hexadecimal 0xff Base-16, prefixed with 0x
Binary 0b11111111 Base-2, prefixed with 0b
Octal 0o377 Base-8, prefixed with 0o
Numeric separator 1_000_000 Underscores for readability (ES2021+), ignored by the engine
BigInt 9007199254740993n Arbitrary-precision integer, trailing n

All of the non-BigInt forms above still produce an ordinary number value — the prefix only affects how the literal is parsed, not how it’s stored.

const decimal = 255;
const hex = 0xff;
const binary = 0b11111111;
const octal = 0o377;
const bigNumber = 1_000_000;
const exponent = 2.5e3;
console.log(decimal, hex, binary, octal, bigNumber, exponent);

Output:

255 255 255 255 1000000 2500

Notice that 0xff, 0b11111111, and 0o377 all evaluate to the same number, 255 — they’re just three different ways of writing the same integer.

Examples

Example 1: Basic arithmetic and type checks

const price = 19.99;
const quantity = 3;
const total = price * quantity;
console.log(total.toFixed(2));
console.log(typeof total);
console.log(Number.isInteger(quantity));
console.log(Number.isInteger(total));

Output:

59.97
number
true
false

Here toFixed(2) rounds and formats the result as a string with exactly two decimal places, which is the standard way to display currency-like values without worrying about tiny floating-point rounding noise. typeof always reports "number" for both integers and decimals, and Number.isInteger() checks whether a value has no fractional component.

Example 2: Converting and parsing strings into numbers

console.log(Number("42"));
console.log(Number("42px"));
console.log(parseInt("42px"));
console.log(parseFloat("3.14meters"));
console.log(Number("   7   "));
console.log(Number(""));
console.log(Number(null));
console.log(Number(undefined));

Output:

42
NaN
42
3.14
7
0
0
NaN

Number() is strict: the entire string must be a valid number (ignoring surrounding whitespace), or the result is NaN. parseInt() and parseFloat() are more forgiving — they parse as much of the string as looks like a number from the left and stop at the first character that doesn’t fit. Note the quirky special cases: an empty string converts to 0, and so does null, while undefined becomes NaN.

Example 3: Precision limits, safe integers, and BigInt

console.log(0.1 + 0.2);
console.log(0.1 + 0.2 === 0.3);
console.log(Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON);
console.log(Number.MAX_SAFE_INTEGER);
console.log(Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2);
const big = 9007199254740993n;
console.log(big);
console.log(typeof big);

Output:

0.30000000000000004
false
true
9007199254740991
true
9007199254740993n
bigint

This example shows the format’s limits in action. 0.1 + 0.2 doesn’t equal 0.3 exactly because none of those three values can be represented exactly in binary. Number.EPSILON (the smallest meaningful difference between two representable numbers near 1) gives a reliable tolerance for comparing floats. And once you go past Number.MAX_SAFE_INTEGER, regular numbers start colliding — MAX_SAFE_INTEGER + 1 and MAX_SAFE_INTEGER + 2 both round to the same stored value. BigInt sidesteps this entirely by using a completely different, arbitrary-precision internal representation.

Under the Hood: Why Floating-Point Math Looks “Wrong”

Think of a double as a sign, an exponent, and a 52-bit fraction, similar to scientific notation in binary: value = sign × mantissa × 2^exponent. Just as 1/3 becomes an infinitely repeating 0.333... in base 10, many ordinary decimal fractions like 0.1 become infinitely repeating binary fractions. Since only 52 bits are available for the mantissa, the engine must round the true value to the nearest representable double. Arithmetic then compounds these tiny rounding errors, which is exactly why 0.1 + 0.2 ends up as 0.30000000000000004 instead of a clean 0.3.

Integers behave better because whole numbers up to 253 − 1 can be represented exactly — there’s no repeating fraction to round. That’s the origin of Number.MAX_SAFE_INTEGER: past that boundary, the gaps between representable numbers grow larger than 1, so some integers become literally indistinguishable from their neighbors. This is a hardware-level constraint of IEEE-754, not something the JavaScript engine can “fix” without changing the whole numeric type — which is precisely why BigInt exists as a separate type with its own arithmetic operators and rules.

Common Mistakes

Mistake 1: Comparing floating-point numbers with strict equality

Because of rounding error, direct equality checks on computed decimals are unreliable.

const a = 0.1 + 0.2;
const b = 0.3;
console.log(a === b);

function areClose(x, y, tolerance = Number.EPSILON) {
  return Math.abs(x - y) < tolerance;
}
console.log(areClose(a, b));

Output:

false
true

Instead of a === b, compare the absolute difference against a small tolerance (as shown), or round both values to a fixed number of decimal places with toFixed() before comparing.

Mistake 2: Trusting === NaN to detect “not a number”

const result = Number("abc");
console.log(result === NaN);
console.log(Number.isNaN(result));
console.log(isNaN("hello"));

Output:

false
true
true

NaN is the only JavaScript value that is never equal to itself, so x === NaN always returns false, even when x genuinely is NaN. Use Number.isNaN(x) instead, which checks the type strictly. The older global isNaN() first coerces its argument to a number, which can produce misleading results for non-numeric inputs like strings — prefer Number.isNaN() in modern code.

Mistake 3: Forgetting that number prefixes change how strings parse

console.log(parseInt("010"));
console.log(parseInt("010", 10));
console.log(parseInt("0x10"));

Output:

10
10
16

Modern engines no longer treat a leading zero as octal, so parseInt("010") correctly returns 10. But a 0x prefix is still auto-detected as hexadecimal, so parseInt("0x10") returns 16, not 10 — a common source of confusion when parsing user input or CSV data that might contain hex-looking strings. Always pass an explicit radix (usually 10) to parseInt() so the intent is unambiguous to both the engine and to anyone reading the code.

Best Practices

  • Use Number.isInteger() and Number.isNaN() instead of the older global isNaN()/relying on implicit coercion — they don’t silently convert their argument first.
  • Never compare floating-point results with ===; use a tolerance (Number.EPSILON) or round both sides first.
  • Always pass a radix to parseInt(), e.g. parseInt(value, 10), even though it’s rarely strictly required today — it documents intent.
  • Use toFixed() only for display formatting (it returns a string); keep doing arithmetic on the original numeric value.
  • For money or anything requiring exact decimal precision, avoid raw floating-point math entirely — work in integer cents, or use a dedicated decimal library.
  • Reach for BigInt only when you truly need integers beyond Number.MAX_SAFE_INTEGER (e.g. large IDs, cryptography); it cannot be mixed with regular numbers in one expression and is slower for typical arithmetic.
  • Prefer numeric separators (1_000_000) for large literals in source code — they improve readability with zero runtime cost.

Practice Exercises

  • Exercise 1: Write a function roundToCents(amount) that takes a floating-point dollar amount and returns it rounded to exactly two decimal places as a number (not a string). Test it with an amount like 19.005.
  • Exercise 2: Write a function isSafeInteger(value) that returns true only if value is a whole number within the safe integer range, and false otherwise. Test it against Number.MAX_SAFE_INTEGER and Number.MAX_SAFE_INTEGER + 10.
  • Exercise 3: Given the array of strings ["12", "7.5abc", "0x1A", "", " 3 "], use Number() or parseFloat() as appropriate to convert each into a proper number, and predict the output before you check it.

Summary

  • JavaScript has a single number type: a 64-bit IEEE-754 double, used for both integers and decimals.
  • Literals can be written in decimal, hex (0x), binary (0b), octal (0o), or exponential form, and can use _ separators for readability.
  • Because decimals are stored in binary, many fractions can’t be represented exactly, causing results like 0.1 + 0.2 !== 0.3.
  • Integers are exact only up to Number.MAX_SAFE_INTEGER (253 − 1); beyond that, use BigInt for exact arithmetic.
  • Number(), parseInt(), and parseFloat() convert strings to numbers with different strictness and stopping rules.
  • Always use Number.isNaN() over === NaN, and compare floats with a tolerance, not strict equality.