Numbers and Number Systems

Table of Contents

  1. Positional Notation: What 345 Really Means
  2. Binary: Base 2
  3. Converting by Hand
  4. Hexadecimal: Base 16
  5. Octal and Literals in JavaScript
  6. toString and parseInt
  7. Hex Colors
  8. Why 0.1 + 0.2 Is Not 0.3
  9. Comparing Floats Safely
  10. Recipe: numbers.js
  11. 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:

PositionHundredsTensOnes
Digit345
Worth3 x 1004 x 105 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 value1286432168421
Power2^72^62^52^42^32^22^12^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:

DecimalBinary
00
11
210
311
4100
5101
81000
131101
25511111111

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:

StepDivideQuotientRemainder
113 / 261
26 / 230
33 / 211
41 / 201

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:

HexDecimalBinary
0 to 90 to 90000 to 1001
a101010
b111011
c121100
d131101
e141110
f151111

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:

PrefixBaseExampleValue
none10255255
0b20b110113
0o80o1715
0x160xff255
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.

PartHexDecimal
Red1e30
Green90144
Blueff255

So #1e90ff is rgb(30, 144, 255), a bright blue. A few well-known ones:

ColorHexRGB
Black#0000000, 0, 0
White#ffffff255, 255, 255
Pure red#ff0000255, 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

  1. By hand. Convert 100 to binary using the divide-by-2 table, then check with (100).toString(2). Then convert 10110 to decimal by adding position values.
  2. Hex shortcuts. Without a calculator, write 10101111 in hex by grouping into fours. Verify it with parseInt("10101111", 2).toString(16).
  3. Build a color. Use rgbToHex to make the hex code for rgb(255, 165, 0) (orange). Then call hexToRgb on the result and check you get the same numbers back.
  4. Make it lighter. Write a function lighten(hex, amount) that adds amount to each channel but never goes above 255. Use Math.min. What should lighten("#f0f0f0", 30) give?
  5. Float detective. Predict, then run: 0.1 * 3, 0.7 + 0.1, 4.35 * 100, and 0.5 + 0.25. Which ones are exact, and why? (Hint: look at which fractions are sums of halves, quarters, and eighths.)
  6. Epsilon choice. Compare 1000000.1 + 0.2 with 1000000.3 using nearlyEqual at epsilons of 1e-9 and 1e-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!