Skip to main content

Numbers & Strings

Not an API list — the surprising mechanics underneath. What the bits actually do.

How a Number is really stored

Every JS number is a 64-bit IEEE 754 double — 1 sign bit, 11 exponent bits, 52 fraction (mantissa) bits. With an implicit leading 1, that gives 53 bits of integer precision. That single fact explains most "number bugs."

The famous one:

0.1 + 0.2;             // => 0.30000000000000004
0.1 + 0.2 === 0.3; // => false

This isn't a JS bug. 0.1 can't be written exactly in binary (just like 1/3 can't be written exactly in decimal). JS stores the nearest double it can, and the tiny error becomes visible when you add. The fix is to compare with a tolerance instead of ===:

const equal = Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON;
equal; // => true (EPSILON is the smallest gap near 1.0)

The 2⁵³ ceiling — integers start skipping

With only 53 bits for the digits, once a number passes 2**53 there aren't enough bits left for the ones place — so representable integers jump in steps of 2, then 4, then 8…

Number.MAX_SAFE_INTEGER;   // => 9007199254740991   (this is 2**53 - 1)

2 ** 53; // => 9007199254740992
2 ** 53 + 1; // => 9007199254740992 the +1 is lost!
2 ** 53 + 1 === 2 ** 53; // => true

2 ** 53 + 2; // => 9007199254740994 +2 survives (step size is 2 here)

"Safe" means: every integer up to MAX_SAFE_INTEGER maps to exactly one double. Above it, several different integers collapse onto the same double — so large counters, IDs, and timestamps can silently break.

-0 is a real, separate value

The sign bit is independent of the digits, so zero comes in two flavors: +0 and -0. They compare equal, but you can tell them apart:

-0 === 0;              // => true     normal comparison can't see it
Object.is(-0, 0); // => false Object.is can — the reliable test
1 / -0; // => -Infinity
1 / 0; // => Infinity

Math.round(-0.1); // => -0 where you accidentally make one

NaN — the value that isn't equal to itself

NaN === NaN;           // => false    the only value where x === x is false
typeof NaN; // => "number" it IS a number type, just "not a real number"

Number.isNaN(NaN); // => true correct way to test
isNaN("hello"); // => true WRONG — global isNaN coerces first
Number.isNaN("hello"); // => false correct — no coercion

Because NaN !== NaN, methods built on === can't find it, but newer ones can:

[NaN].indexOf(NaN);    // => -1    indexOf uses ===, never matches
[NaN].includes(NaN); // => true includes uses SameValueZero, which matches

Coercion traps

parseInt reads a string left to right and stops at the first character it can't use:

parseInt("42px");      // => 42     stops at "p"
parseInt("px42"); // => NaN first char already invalid
parseInt("0x10"); // => 16 "0x" makes it read hex
parseInt(" 10 "); // => 10 leading space is skipped

parseInt(0.0000005); // => 5 !! the number prints as "5e-7", parseInt reads the "5"

Always pass a radix so you control the base:

parseInt("11", 2);     // => 3      binary
parseInt("zz", 36); // => 1295 base-36

Number(...) is stricter — it converts the whole string or fails:

Number("42px");        // => NaN    (parseInt("42px") was 42)
Number(""); // => 0 (parseInt("") was NaN)
Number(" 10 "); // => 10 trims whitespace

Coercing arrays/objects with unary + runs them through String() first:

+[];                   // => 0      []  becomes ""  becomes 0
+[5]; // => 5 [5] becomes "5" becomes 5
+[1, 2]; // => NaN "1,2" isn't a number
+{}; // => NaN "[object Object]"

Only + is overloaded for strings — every other operator forces numbers:

1 + "2";               // => "12"   string concatenation
1 - "2"; // => -1 "2" coerced to number
"6" / "2"; // => 3

.toFixed can round the "wrong" way

(1.005).toFixed(2);    // => "1.00"   you expected "1.01"

Not a rounding mistake — 1.005 is actually stored as 1.00499999..., so rounding down is correct for the value that's really there. For money, work in integer cents or use a decimal library.

Bitwise operators secretly cast to int32

Every bitwise operator first converts its operands to a signed 32-bit integer. People exploit this for quick truncation:

~~4.7;                 // => 4    double-NOT drops the decimal part
4.7 | 0; // => 4 OR-with-0 does the same
"5" | 0; // => 5 also coerces the string to a number

But it breaks above 2³¹, because the value overflows the 32-bit range:

2 ** 31 | 0;           // => -2147483648   wraps around to negative
-1 >>> 0; // => 4294967295 >>> is the only unsigned shift

So ~~x and x | 0 are fine for small numbers, but use Math.trunc(x) when the value could be large.

Small surprises worth knowing

Math.max();            // => -Infinity   (no args → the "smallest possible")
Math.min(); // => Infinity

(255).toString(16); // => "ff" convert to hex (radix 2–36)
(255).toString(2); // => "11111111"
(0.1).toString(2); // => "0.0001100110011..." watch 0.1 repeat forever in binary

BigInt — for integers bigger than 2⁵³

BigInt holds whole numbers of any size, exactly. Write a literal with n, or call BigInt():

10n ** 100n;           // => 1000...0n   an exact googol
10 ** 100; // => 1e+100 a normal number already lost precision

typeof 10n; // => "bigint"

The catches:

10n === 10;            // => false   different types
10n == 10; // => true loose equality crosses types

5n / 2n; // => 2n integer division — fractions are dropped
5n + 1n; // => 6n
5n + 1; // throws TypeError — can't mix BigInt with Number

JSON.stringify(10n); // throws TypeError — BigInt isn't JSON-serializable
Math.sqrt(4n); // throws TypeError — Math doesn't accept BigInt

Good for IDs, database keys, currency-in-cents, crypto. Build from a string, never a big number literal — the literal loses precision before BigInt ever sees it:

BigInt("9007199254740993");  // => 9007199254740993n   exact
BigInt(9007199254740993); // => 9007199254740992n already wrong

Strings are UTF-16 code units, not characters

.length and str[i] count 16-bit code units, not the characters a human sees. Anything beyond U+FFFF — most emoji, rarer CJK, math symbols — takes two code units (a "surrogate pair"):

"abc".length;          // => 3   as expected
"😀".length; // => 2 one emoji, but counts as two!

"😀"[0]; // => "\uD83D" just the first half — a broken character
"a😀b".charAt(1); // => "\uD83D" slicing splits the emoji apart

Iteration helpers understand full characters; index-based access doesn't:

[..."😀"].length;          // => 1    spread iterates by code point
"😀".codePointAt(0); // => 128512 the full code point
"😀".charCodeAt(0); // => 55357 only the first half
How you read the stringCounts byKeeps emoji whole?
.length, str[i], slice, charCodeAtcode unit (16-bit)
for...of, [...str], codePointAtcode point
Intl.Segmentergrapheme (human character)

Even code points aren't the full story. "👍🏽" (thumb + skin tone) is 2 code points; "🇮🇳" (flag) is 2 regional-indicator code points. Only Intl.Segmenter counts what a person calls one character:

const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
[...seg.segment("👍🏽")].length; // => 1 (spread gives 2, .length gives 4)

Two strings that look identical but aren't equal

Unicode can encode the same glyph two ways — one precomposed character, or a letter plus a combining mark:

const a = "café";          // 'é' is one code point (U+00E9)
const b = "café"; // 'é' is 'e' + a combining accent (two code points)

a === b; // => false same on screen, different bytes
a.length; // => 4
b.length; // => 5

a.normalize() === b.normalize(); // => true normalize first, then compare

This is why search and de-duplication quietly fail on accented text. Run user input through .normalize() before comparing or storing.

Comparison and sorting use code-unit order, not language

"10" < "9";                // => true   compares "1" vs "9" character by character
"Z" < "a"; // => true all uppercase sorts before lowercase (ASCII)
["banana", "Apple", "cherry"].sort(); // => ["Apple", "banana", "cherry"]

That capital-clumping is rarely what users want. Use locale-aware comparison for human ordering:

"apple".localeCompare("Apple", "en");   // => -1 / 0 / 1, accent- and case-correct
["banana", "Apple", "cherry"].sort((x, y) => x.localeCompare(y));
// => ["Apple", "banana", "cherry"] ordered the way people expect

Other things worth knowing

Strings are immutable — every method returns a new string, and you can't edit one in place:

let s = "hello";
s[0] = "H"; // silently does nothing (throws in strict mode)
s; // => "hello"
s = "H" + s.slice(1); // => "Hello" you must build a new string

A few methods that trip people up:

"a-a-a".replace("a", "X");    // => "X-a-a"   a string argument replaces only the FIRST
"a-a-a".replaceAll("a", "X"); // => "X-X-X" use replaceAll (or a /a/g regex)

"5,,3".split(","); // => ["5", "", "3"] empty pieces are kept
"abc".at(-1); // => "c" negative index counts from the end
"42".padStart(5, "0"); // => "00042" handy for zero-padding

String.raw is a template tag that ignores backslash escapes — useful for Windows paths and regex source:

`C:\new`;              // => "C:" + newline + "ew"   \n became a newline
String.raw`C:\new`; // => "C:\new" taken literally

And never use the String constructor — it makes objects, not primitives:

typeof "a";                    // => "string"
typeof new String("a"); // => "object"
new String("a") === new String("a"); // => false two different objects

The bug table

Looks likeActuallyWhy
0.1 + 0.2 === 0.3falsebinary can't store 0.1 exactly
2**53 + 1 === 2**53trueout of mantissa bits
NaN === NaNfalseNaN is never equal to itself
Object.is(-0, 0)falsethe sign bit is separate
2**31 | 0negativebitwise casts to int32
"😀".length2UTF-16 surrogate pair
"café" === "café"falseneeds .normalize()
"10" < "9"truecharacter-by-character compare
(1.005).toFixed(2)"1.00"1.005 is stored as 1.00499…

Resources — floating point in depth

Intuition

Interactive (see the bits)

Canonical

JS-specific