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 string | Counts by | Keeps emoji whole? |
|---|---|---|
.length, str[i], slice, charCodeAt | code unit (16-bit) | ✗ |
for...of, [...str], codePointAt | code point | ✓ |
Intl.Segmenter | grapheme (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 like | Actually | Why |
|---|---|---|
0.1 + 0.2 === 0.3 | false | binary can't store 0.1 exactly |
2**53 + 1 === 2**53 | true | out of mantissa bits |
NaN === NaN | false | NaN is never equal to itself |
Object.is(-0, 0) | false | the sign bit is separate |
2**31 | 0 | negative | bitwise casts to int32 |
"😀".length | 2 | UTF-16 surrogate pair |
"café" === "café" | false | needs .normalize() |
"10" < "9" | true | character-by-character compare |
(1.005).toFixed(2) | "1.00" | 1.005 is stored as 1.00499… |
Resources — floating point in depth
Intuition
- 0.30000000000000004.com — why
0.1 + 0.2breaks, across languages - floating-point-gui.de — "What Every Programmer Should Know About Floating-Point Arithmetic"
Interactive (see the bits)
- float.exposed — flip sign/exponent/mantissa bits; switch half/single/double
- Bartosz Ciechanowski — Exposing Floating Point — interactive visual essay
Canonical
- Goldberg — What Every Computer Scientist Should Know About FP Arithmetic — the definitive paper
- IEEE 754 (Wikipedia) — all formats, rounding modes, special values
- Double-precision format (Wikipedia) — exact bit layout
JS-specific