Number Bases: Hex, Two's Complement, and Float Errors
Published 2026-10-31
Converting between bases is school-level arithmetic. What actually causes production bugs is everything downstream: how negatives are stored, why decimals are not exact, and when a large integer quietly changes value.
The mapping worth memorising
| Decimal | Binary | Hex |
|---|---|---|
| 0 | 0000 | 0 |
| 5 | 0101 | 5 |
| 10 | 1010 | A |
| 15 | 1111 | F |
| 255 | 1111 1111 | FF |
| 4096 | 1 0000 0000 0000 | 1000 |
Hex exists because 4 binary digits map exactly to 1 hex digit. One byte (8 bits) is exactly two hex digits — which is why #FF8800 reads as three bytes: 255, 136, 0.
Negative numbers: two's complement
There is no minus sign in memory. Negatives use two's complement: invert every bit, then add one.
5 = 0000 0101
~5 = 1111 1010
+1 = 1111 1011 → 0xFB (= -5 in 8-bit)
The payoff is that subtraction becomes addition: 7 + (-5) is 0000 0111 + 1111 1011 = 1 0000 0010, and the overflow bit is simply dropped.
The cost is an asymmetric range: 8 bits cover -128 … 127, not -127 … 127. -128 has no positive counterpart, which produces a genuine bug:
Math.abs(-2147483648) // still -2147483648
Why 0.1 + 0.2 !== 0.3
0.1 + 0.2 // 0.30000000000000004
This is not a JavaScript bug. Every language using IEEE 754 doubles behaves the same way.
Binary can only represent fractions whose denominator is a power of two. 0.5, 0.25, 0.125 are exact. 0.1 is 1/10, and 10 has a prime factor of 5, so in binary it is a repeating fraction:
0.1 (decimal) = 0.0001100110011001100... (binary, repeating)
Stored in 64 bits, it is truncated to an approximation slightly larger than 0.1. Add two approximations and the error becomes visible.
| Scenario | Fix |
|---|---|
| Money | Use integer cents. Store 1234, divide by 100 only for display |
| Comparison | Math.abs(a - b) < 1e-9 instead of === |
| Display | toFixed(2) / Intl.NumberFormat, applied only at the end |
| High precision | A decimal library (decimal.js, big.js) |
The dangerous case is accumulating money in floats — ten thousand additions of 0.01 drift by cents, and cents are what reconciliation disputes are made of.
Integers above 2^53 are not safe
JavaScript's Number is a double, so exact integers stop at 2^53 - 1 = 9007199254740991 (16 decimal digits).
9007199254740993 // 9007199254740992
JSON.parse('{"id":9007199254740993}').id // 9007199254740992
Snowflake IDs, 18-digit order numbers and 18-digit national ID numbers all sit past that boundary. This is why "the last two digits of the ID changed in the frontend".
The fix is at the API boundary: serialise large integers as strings, and never Number() them on the client. When you genuinely need arithmetic, use BigInt.
Two parseInt traps
parseInt('12px') // 12 — stops at the first invalid character, no error
parseInt('') // NaN
Number('') // 0 ← different from parseInt
Number(null) // 0
Number(undefined) // NaN
parseInt('12px') === 12 is the dangerous one: malformed input passes validation silently. If you need strictness, validate the shape with a regex first, then use Number().
Bitwise operators truncate to 32 bits
JavaScript bitwise operators (& | ^ << >>) convert operands to 32-bit signed integers first, discarding anything above bit 31:
1 << 31 // -2147483648, not 2147483648
2 ** 32 | 0 // 0
So use them only when you know the values fit in 32 bits: permission bitmasks, colour channel packing, small hashes. Also note >> preserves the sign bit while >>> fills with zeros — using the wrong one turns "take the high byte" into a negative number.
Checking by hand
To verify a hex value, see what a byte sequence means in decimal, or convert a batch of numbers, use Base Converter — binary, octal, decimal and hex update together.
For bulk numeric cleanup (padding, truncating, thousands separators), Number Tools is faster than spreadsheet formulas.
Three rules to keep:
- Two hex digits = one byte; hex is just shorthand for binary.
- Floats cannot represent 0.1 exactly — money belongs in integer cents.
- Integers above 2^53 lose precision in JavaScript — pass them as strings.