Numbers and Number Systems
Table of Contents
- Positional Notation: What 345 Really Means
- Binary: Base 2
- Converting by Hand
- Hexadecimal: Base 16
- Octal and Literals in JavaScript
- toString and parseInt
- Hex Colors
- Why 0.1 + 0.2 Is Not 0.3
- Comparing Floats Safely
- Recipe: numbers.js
- Try It Yourself
1. Positional Notation: What 345 Really Means
You read the number 345 without thinking. But each digit counts for a
different amount depending on its position:
| Position | Hundreds | Tens | Ones |
|---|---|---|---|
| Digit | 3 | 4 | 5 |
| Worth | 3 x 100 | 4 x 10 | 5 x 1 |
So 345 = 3 x 100 + 4 x 10 + 5 x 1. Look at the position values: 1, 10,
100. They are powers of 10: 10^0, 10^1, 10^2. (10^2 means
10 x 10. In JavaScript you write it 10 ** 2.)
We use ten digits, 0 to 9, and the position values grow by a factor
of ten. That is called base 10, or decimal. The only reason we
use base 10 is that humans have ten fingers.
Nothing forces us to use ten. A number system with base b uses b
digits, and each position is worth b times the one to its right. Computers
prefer base 2, and programmers often write base 16.
2. Binary: Base 2
A computer circuit is either on or off, so the natural system has two
digits: 0 and 1. This is binary. A single binary digit is a
bit. The position values are powers of 2:
| Position value | 128 | 64 | 32 | 16 | 8 | 4 | 2 | 1 |
|---|---|---|---|---|---|---|---|---|
| Power | 2^7 | 2^6 | 2^5 | 2^4 | 2^3 | 2^2 | 2^1 | 2^0 |
To read the binary number 1101, add up the position values where there
is a 1:
1101 = 1 x 8 + 1 x 4 + 0 x 2 + 1 x 1
= 8 + 4 + 0 + 1
= 13
Eight bits make a byte, and a byte can hold 0 to 255 (that is
2^8 = 256 different values). If you want a refresher on bits and bytes,
see Bits and Bytes.
Counting in binary looks like this:
| Decimal | Binary |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 2 | 10 |
| 3 | 11 |
| 4 | 100 |
| 5 | 101 |
| 8 | 1000 |
| 13 | 1101 |
| 255 | 11111111 |
Just like 9 rolls over to 10 in decimal, 1 rolls over to 10 in
binary. A useful pattern: n bits can represent 2^n different values.
Eight bits give 256, sixteen give 65,536, and 32 give 4,294,967,296.
3. Converting by Hand
Binary to decimal: add the position values, as above.
Decimal to binary: divide by 2 again and again. Write down each remainder. The remainders, read from the last to the first, are the binary digits.
Convert 13:
| Step | Divide | Quotient | Remainder |
|---|---|---|---|
| 1 | 13 / 2 | 6 | 1 |
| 2 | 6 / 2 | 3 | 0 |
| 3 | 3 / 2 | 1 | 1 |
| 4 | 1 / 2 | 0 | 1 |
Read the remainders bottom to top: 1101. It matches the earlier result.
We can turn that table into code. This uses % (remainder) and
Math.floor (round down):
function manualBinary(n) {
let out = "";
while (n > 0) {
out = (n % 2) + out; // put the remainder at the FRONT
n = Math.floor(n / 2); // integer division
}
return out || "0";
}
console.log(manualBinary(13)); // 1101
console.log(manualBinary(255)); // 11111111
console.log(manualBinary(0)); // 0
Going the other way, you can build a number from its digits left to right. For each digit, multiply what you have so far by the base, then add the digit:
function fromDigits(text, base) {
let total = 0;
for (const ch of text) {
total = total * base + parseInt(ch, base);
}
return total;
}
console.log(fromDigits("1101", 2)); // 13
console.log(fromDigits("ff", 16)); // 255
console.log(fromDigits("755", 8)); // 493
Check by hand for 1101: start at 0, then 0*2+1 = 1, 1*2+1 = 3,
3*2+0 = 6, 6*2+1 = 13. This trick is called Horner's method, and
it is how most parsers read numbers.
4. Hexadecimal: Base 16
Binary is long. The number 255 is 11111111, eight digits. Hexadecimal
(hex for short) is base 16, and it is a compact way to write binary
because one hex digit equals exactly four bits.
Base 16 needs sixteen digits. We have 0 to 9, so letters fill the
rest:
| Hex | Decimal | Binary |
|---|---|---|
0 to 9 | 0 to 9 | 0000 to 1001 |
a | 10 | 1010 |
b | 11 | 1011 |
c | 12 | 1100 |
d | 13 | 1101 |
e | 14 | 1110 |
f | 15 | 1111 |
Position values are powers of 16: 1, 16, 256, 4096. So ff is
15 x 16 + 15 x 1 = 255, and 1e is 1 x 16 + 14 = 30.
Because each hex digit is four bits, converting between binary and hex needs no arithmetic. Group the bits in fours from the right:
1101 1111 (binary)
d f (hex)
So 11011111 is df in hex. A byte is always exactly two hex digits,
00 to ff. You will see hex in memory addresses, color codes, error
codes, hashes, and UUIDs.
5. Octal and Literals in JavaScript
Octal is base 8: digits 0 to 7, and each octal digit equals
three bits. Its main modern use is Unix file permissions like 755, which
we meet in the next chapter.
JavaScript lets you write numbers in any of these bases directly. These are called literals, and they all produce ordinary numbers:
| Prefix | Base | Example | Value |
|---|---|---|---|
| none | 10 | 255 | 255 |
0b | 2 | 0b1101 | 13 |
0o | 8 | 0o17 | 15 |
0x | 16 | 0xff | 255 |
console.log(0b1101); // 13
console.log(0o17); // 15
console.log(0xff); // 255
console.log(0xff === 255); // true
The number does not "remember" how you wrote it. 0xff and 255 are
the same value. Base only matters when you write the number or print it.
6. toString and parseInt
To print a number in another base, use number.toString(base). To
read text in another base, use parseInt(text, base).
console.log((13).toString(2)); // 1101
console.log((255).toString(16)); // ff
console.log((255).toString(8)); // 377
console.log((10).toString(16)); // a
console.log(parseInt("1101", 2)); // 13
console.log(parseInt("ff", 16)); // 255
console.log(parseInt("FF", 16)); // 255 (case does not matter)
console.log(parseInt("777", 8)); // 511
The parentheses in (13).toString(2) are needed because 13.toString
would look like a decimal point to JavaScript. Always pass the base to
parseInt. Leaving it out is a classic source of bugs.
Be aware of two behaviors:
console.log(parseInt("12px")); // 12 (stops at the first bad character)
console.log(parseInt("xyz")); // NaN (not a number)
console.log((10).toString(16).padStart(2, "0")); // 0a
parseInt quietly ignores the rest of the text, and NaN means "not a
number". padStart(2, "0") adds leading zeros until the text is two
characters long, which we need for colors.
7. Hex Colors
A web color like #1e90ff is three bytes written in hex: red, green, and
blue. Each color channel is 00 to ff, which is 0 to 255.
| Part | Hex | Decimal |
|---|---|---|
| Red | 1e | 30 |
| Green | 90 | 144 |
| Blue | ff | 255 |
So #1e90ff is rgb(30, 144, 255), a bright blue. A few well-known
ones:
| Color | Hex | RGB |
|---|---|---|
| Black | #000000 | 0, 0, 0 |
| White | #ffffff | 255, 255, 255 |
| Pure red | #ff0000 | 255, 0, 0 |
The total number of colors is 256 x 256 x 256 = 16,777,216, or
2^24. That is where the phrase "24-bit color" comes from.
Converting is just parseInt(..., 16) for each pair of characters, and
toString(16) with padding going back. A channel like 5 must become
05, not 5, or the code would be too short. That mistake is very
common.
8. Why 0.1 + 0.2 Is Not 0.3
Whole numbers are stored exactly. Fractions are trickier.
In decimal, you cannot write 1/3 exactly: it is 0.3333... forever.
That is a problem of the base. 1/3 is easy in base 3 and impossible in
base 10. The same thing happens in binary with fractions like 0.1.
Positions after the binary point are worth 1/2, 1/4, 1/8, and so on.
Some fractions are exact in binary:
console.log((0.5).toString(2)); // 0.1
console.log((0.75).toString(2)); // 0.11 (1/2 + 1/4)
console.log((0.25).toString(2)); // 0.01
But 0.1 (one tenth) is not a sum of a few such pieces. In binary it
repeats forever:
console.log((0.1).toString(2));
// 0.0001100110011001100110011001100110011001100110011001101
JavaScript stores numbers as 64-bit floating-point values (the IEEE
754 standard, called a double), which has room for about 53 binary
digits. So it must round 0.1 and store a number very slightly
different from one tenth. You can see the real stored value:
console.log((0.1).toFixed(25)); // 0.1000000000000000055511151
console.log((0.2).toFixed(25)); // 0.2000000000000000111022302
console.log((0.3).toFixed(25)); // 0.2999999999999999888977698
Each number carries a tiny error. When you add 0.1 + 0.2, the errors
combine and the answer lands just above 0.3:
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
console.log(0.1 * 3); // 0.30000000000000004
This is not a JavaScript bug. Python, Java, C, and Swift give the same answer, because they all use the same standard. Numbers with a fractional part are approximations, and this is what you should remember:
- Never compare floats with
===after calculations. - Never store money as a float. Chapter 7 shows the fix: whole numbers of cents or riel.
- Whole numbers up to
Number.MAX_SAFE_INTEGER(9007199254740991) are exact. Larger ones are not.
9. Comparing Floats Safely
If you cannot ask "are these exactly equal?", ask "are these close enough?". Subtract them and check that the absolute value (the distance, ignoring the sign) is smaller than a tiny threshold called epsilon:
const a = 0.1 + 0.2;
const b = 0.3;
console.log(Math.abs(a - b)); // 5.551115123125783e-17
console.log(Math.abs(a - b) < 1e-9); // true
The number 5.55e-17 is scientific notation: 0.0000000000000000555.
It is so small that for any practical purpose the two values are equal.
JavaScript has a built-in Number.EPSILON (about 2.22e-16), but it is
only the gap between 1 and the next number. It is too strict for bigger
values or values that went through several calculations. For everyday
code, choose your own epsilon that suits the problem: 1e-9 for general
use, 0.005 if you only care about half a cent.
A second tool is rounding for display, as in toFixed:
console.log((0.1 + 0.2).toFixed(2)); // 0.30
toFixed(2) returns text, not a number. Use it to show a value, not
to compare it.
10. Recipe: numbers.js
Create src/numbers.js. It converts between bases, converts hex colors,
and compares floats:
export function toBinary(n) {
return n.toString(2);
}
export function toHex(n) {
return n.toString(16);
}
export function fromHex(text) {
return parseInt(text, 16);
}
export function hexToRgb(hex) {
const clean = hex.replace("#", "");
return {
r: fromHex(clean.slice(0, 2)),
g: fromHex(clean.slice(2, 4)),
b: fromHex(clean.slice(4, 6)),
};
}
function twoDigitHex(n) {
return toHex(n).padStart(2, "0");
}
export function rgbToHex(r, g, b) {
return "#" + twoDigitHex(r) + twoDigitHex(g) + twoDigitHex(b);
}
export function nearlyEqual(a, b, epsilon = 1e-9) {
return Math.abs(a - b) < epsilon;
}
A few notes. hex.replace("#", "") removes the # if it is there, so
both "#1e90ff" and "1e90ff" work. slice(0, 2) takes the first two
characters (red), slice(2, 4) the next two (green), slice(4, 6) the
last two (blue). epsilon = 1e-9 is a default parameter: callers
can leave it out.
Now add it to src/index.js:
// Every module we write is re-exported from here.
export const version = "0.1.0";
export * from "./numbers.js";
Replace demo.js with:
import {
version, toBinary, toHex, fromHex,
hexToRgb, rgbToHex, nearlyEqual,
} from "./src/index.js";
console.log(`mathlab v${version}`);
console.log(toBinary(13)); // 1101
console.log(toBinary(255)); // 11111111
console.log(toHex(255)); // ff
console.log(toHex(3054)); // bee
console.log(fromHex("ff")); // 255
console.log(hexToRgb("#1e90ff")); // { r: 30, g: 144, b: 255 }
console.log(rgbToHex(30, 144, 255)); // #1e90ff
console.log(rgbToHex(0, 5, 10)); // #00050a
console.log(0.1 + 0.2 === 0.3); // false
console.log(nearlyEqual(0.1 + 0.2, 0.3)); // true
console.log(nearlyEqual(1, 1.1)); // false
console.log(nearlyEqual(1, 1.0000000001, 1e-6)); // true
Run it:
node demo.js
Expected output:
mathlab v0.1.0
1101
11111111
ff
bee
255
{ r: 30, g: 144, b: 255 }
#1e90ff
#00050a
false
true
false
true
(The false printed right after #00050a is the plain === comparison,
the true after it is nearlyEqual.)
Notice rgbToHex(0, 5, 10) gives #00050a, with leading zeros kept.
Remove the padStart call and see what breaks.
11. Try It Yourself
- By hand. Convert 100 to binary using the divide-by-2 table, then
check with
(100).toString(2). Then convert10110to decimal by adding position values. - Hex shortcuts. Without a calculator, write
10101111in hex by grouping into fours. Verify it withparseInt("10101111", 2).toString(16). - Build a color. Use
rgbToHexto make the hex code forrgb(255, 165, 0)(orange). Then callhexToRgbon the result and check you get the same numbers back. - Make it lighter. Write a function
lighten(hex, amount)that addsamountto each channel but never goes above255. UseMath.min. What shouldlighten("#f0f0f0", 30)give? - Float detective. Predict, then run:
0.1 * 3,0.7 + 0.1,4.35 * 100, and0.5 + 0.25. Which ones are exact, and why? (Hint: look at which fractions are sums of halves, quarters, and eighths.) - Epsilon choice. Compare
1000000.1 + 0.2with1000000.3usingnearlyEqualat epsilons of1e-9and1e-12. What changes? Why might a bigger number need a bigger epsilon?
You can now read and write numbers in any base. Next, in Bitwise Operations and Flags, we work with the individual bits of those numbers to pack many yes/no settings into a single value, the way Unix permissions do.
Buy Me a Coffee
Found this tutorial helpful? Consider buying me a coffee to support more free content!