Boolean Logic

Table of Contents

  1. Two Values, Endless Decisions
  2. AND, OR and NOT
  3. Truth Tables
  4. XOR: Exactly One
  5. Precedence and Parentheses
  6. De Morgan's Laws
  7. Short-Circuit Evaluation
  8. Untangling a Permission Check
  9. Recipe: logic.js for mathlab
  10. Try It Yourself

1. Two Values, Endless Decisions

A boolean has only two possible values: true and false. That sounds too small to matter, yet every if, every while, every filter and every permission check in your code boils down to one boolean.

Boolean logic is the math of combining booleans. George Boole wrote it down in the 1800s, long before computers, and it turned out to be exactly what a computer chip does: a chip is billions of tiny switches combining 1s and 0s.

In chapter 3 you combined bits with &, |, ^, and ~. Those operators work on every bit of a number at the same time. This chapter is about the same ideas applied to single true/false values with &&, ||, and !. They are cousins. Once you know one set, you know the other.

If you want to refresh the syntax first, see Operators and Conditions.

2. AND, OR and NOT

There are three basic operations:

OperationJavaScriptMeaning
ANDa && btrue only when both are true
ORa || btrue when at least one is true
NOT!aflips true to false and back

In plain words: "can edit" if you are the author and the post is not locked. "Can see" if you are an admin or the owner. "Not" simply reverses the answer.

const isAuthor = true;
const isLocked = false;

console.log(isAuthor && !isLocked); // true
console.log(isAuthor && isLocked);  // false
console.log(isAuthor || isLocked);  // true
console.log(!isAuthor);             // false

Read isAuthor && !isLocked aloud as "is the author and is not locked". If a condition is hard to read aloud, it is probably too complicated.

3. Truth Tables

With only two values per input, you can list every possible case. That list is called a truth table. It is the most reliable way to check what a condition really does, because it leaves no case out.

Here is AND for two inputs, a and b:

aba && b
FFF
FTF
TFF
TTT

And OR:

aba || b
FFF
FTT
TFT
TTT

Notice the count of rows: two inputs give 2 × 2 = 4 rows. Three inputs give 2 × 2 × 2 = 8 rows. In general, n inputs give 2ⁿ rows. (This is the same doubling you met in chapter 2: each extra input is one more bit.) Ten inputs would give 1,024 rows, which a computer checks in a blink, but would exhaust a human. That is why we let code build the tables, as in the recipe later.

4. XOR: Exactly One

There is a fourth operation people forget: XOR, short for "exclusive or". It is true when the two inputs are different, meaning exactly one of them is true.

aba XOR b
FFF
FTT
TFT
TTF

Compare it with OR: the only difference is the last row. OR says "one or both"; XOR says "one, but not both".

JavaScript has no ^^ operator. For booleans, !== does the job, because "the two values are different" is exactly XOR:

const a = true;
const b = false;

console.log(a !== b); // true  (XOR)
console.log(a ^ b);   // 1     (bitwise XOR also works, but gives a number)

Everyday XOR situations: "pay by cash or card, but not both", "the toggle changes state each time it is clicked" (isOn = isOn !== clicked), or "exactly one of the two password fields is empty".

5. Precedence and Parentheses

When one expression mixes operators, JavaScript needs an order, just like * happens before + in arithmetic. For booleans the order is:

  1. ! first,
  2. then &&,
  3. then ||.

So a || b && c means a || (b && c), not (a || b) && c.

const a = true, b = false, c = false;

console.log(a || b && c);   // true   -> a || (b && c)
console.log((a || b) && c); // false

Same inputs, different answers. Do not rely on memorizing precedence. Use parentheses whenever you mix && and ||, even where they are not strictly required. A reader should never have to run the precedence rules in their head.

6. De Morgan's Laws

Augustus De Morgan noticed that when you put a ! in front of a parenthesis, you can push the ! inside, as long as you swap AND with OR:

!(a && b)   is the same as   !a || !b
!(a || b)   is the same as   !a && !b

In words: "not (A and B)" means "not A, or not B". "Not (A or B)" means "not A, and not B".

A real sentence shows why it makes sense. Take the rule "you may enter if NOT (under 18 or without a ticket)". Who gets in? Only people who are not under 18 and do have a ticket. The OR flipped to AND.

You can confirm the law with a truth table:

ab!(a && b)!a || !b
FFTT
FTTT
TFTT
TTFF

The last two columns match on every row, so the expressions are equivalent. This is how you prove a rewrite is safe without any fancy proof: if every row of the table agrees, the two conditions are the same.

De Morgan's laws matter most when they let you remove a ! that makes code hard to read:

const age = 20;
const hasTicket = true;

// Hard to read
const blocked1 = !(age >= 18 && hasTicket);

// Same thing, De Morgan applied. Note that !(age >= 18) is age < 18.
const blocked2 = age < 18 || !hasTicket;

console.log(blocked1, blocked2); // false false

Applying the law to comparisons also flips the comparison operator:

OriginalEquivalent
!(x > 5)x <= 5
!(x >= 5)x < 5
!(x === 5)x !== 5

A common bug is to flip > into < and forget the equal case. The opposite of "greater than" is "less than or equal to", not "less than". Truth tables (or tests at the boundary value) catch this.

7. Short-Circuit Evaluation

&& and || do not always evaluate both sides. JavaScript reads left to right and stops as soon as the answer is certain:

  • In a && b, if a is false, the answer is already false, so b is never looked at.
  • In a || b, if a is true, the answer is already true, so b is never looked at.

You can prove it with a function that announces itself:

let calls = 0;
const check = () => {
  calls++;
  return true;
};

false && check(); // check never runs
true || check();  // check never runs
console.log(calls); // 0

This is more than a speed trick. It is a safety tool. Put the cheap or safe test first, and the dangerous one second:

const user = { name: "Dara", profile: null };

// Safe: if profile is null, the right side is skipped
console.log(user.profile && user.profile.city); // null

// Without the guard, this would throw a TypeError:
// user.profile.city

// The modern shortcut for the same idea
console.log(user.profile?.city); // undefined

There is one more twist in JavaScript. && and || do not return true or false. They return one of the two operands:

console.log(0 || "default");  // "default"
console.log("Dara" || "x");   // "Dara"
console.log(true && "ready"); // "ready"
console.log(null && 1);       // null

That is why name || "Anonymous" works as a default value. Just remember that || treats 0 and "" as "missing", which can be wrong for a quantity of zero. Use ?? there, as covered in Operators and Conditions.

Finally, put the cheapest check first. If one condition is a quick variable test and the other asks the database, write the quick one on the left, and the database call is skipped whenever the quick test already decides the answer.

8. Untangling a Permission Check

Here is the problem this chapter has been building toward. The rules for editing a blog post are:

  1. Admins can always edit.
  2. Nobody else can edit a locked post.
  3. The author can edit their own post.
  4. An editor can edit anyone's post, unless it is archived.

A tired developer wrote this:

function canEditOld(user, post) {
  if (
    !(user.role !== "admin") ||
    (!post.locked &&
      (post.authorId === user.id || (user.role === "editor" && !post.archived)))
  ) {
    return true;
  }
  return false;
}

It works, but nobody can tell. Let's clean it up in four steps.

Step 1: remove double negatives. !(user.role !== "admin") is "not (not equal)", and two NOTs cancel out. It is simply user.role === "admin".

Step 2: return the boolean directly. if (condition) return true; return false; is the same as return condition;.

Step 3: split into early returns. An early return exits the function as soon as the answer is known, like a short circuit but for whole steps. Each rule becomes one flat line, in the order you would explain the rules to a colleague:

function canEdit(user, post) {
  if (user.role === "admin") return true;   // rule 1
  if (post.locked) return false;            // rule 2
  if (post.authorId === user.id) return true; // rule 3
  return user.role === "editor" && !post.archived; // rule 4
}

Step 4: prove it did not change. Never trust a refactor by eye. Run both versions on every combination of the inputs that matter (role, locked, archived, author-or-not) and compare:

let same = true;
let cases = 0;

for (const role of ["admin", "editor", "viewer"]) {
  for (const locked of [true, false]) {
    for (const archived of [true, false]) {
      for (const authorId of [1, 2]) {
        const user = { id: 1, role };
        const post = { authorId, locked, archived };
        cases++;
        if (canEditOld(user, post) !== canEdit(user, post)) same = false;
      }
    }
  }
}

console.log(same, cases); // true 24

Three roles times two times two times two is 24 cases, and all agree. This is the same idea as a truth table, and it is the heart of safe refactoring: if all the inputs are enumerable, check them all.

Early returns flatten nesting. Compare "if admin, then ... else if ... else ..." with a list of simple rules read top to bottom. The second is easier to read and easier to change when a fifth rule arrives.

9. Recipe: logic.js for mathlab

Now add the logic module to mathlab. Create src/logic.js:

// All true/false combinations for n inputs.
export function boolCombos(n) {
  const rows = [];
  for (let i = 0; i < 2 ** n; i++) {
    const row = [];
    for (let bit = n - 1; bit >= 0; bit--) {
      row.push(((i >> bit) & 1) === 1);
    }
    rows.push(row);
  }
  return rows;
}

// fn takes n booleans; returns [{ inputs: [...], result }, ...]
export function truthTable(fn) {
  return boolCombos(fn.length).map((inputs) => ({
    inputs,
    result: fn(...inputs),
  }));
}

// Print a table to the console.
export function printTable(fn) {
  for (const { inputs, result } of truthTable(fn)) {
    const cells = inputs.map((v) => (v ? "T" : "F")).join(" ");
    console.log(`${cells} | ${result ? "T" : "F"}`);
  }
}

// Do two functions give the same result for every combination?
export function areEquivalent(f, g) {
  if (f.length !== g.length) return false;
  return boolCombos(f.length).every((row) => f(...row) === g(...row));
}

// De Morgan's laws, checked by brute force.
export const deMorganAnd = areEquivalent(
  (a, b) => !(a && b),
  (a, b) => !a || !b,
);
export const deMorganOr = areEquivalent(
  (a, b) => !(a || b),
  (a, b) => !a && !b,
);

// The cleaned-up permission rules from section 8.
export function canEdit(user, post) {
  if (user.role === "admin") return true;
  if (post.locked) return false;
  if (post.authorId === user.id) return true;
  return user.role === "editor" && !post.archived;
}

A few things to notice. fn.length is the number of parameters a function declares, so truthTable knows how many inputs to try without being told. i >> bit shifts the bits of i right (from chapter 3) and & 1 picks the lowest bit, which is how a counter from 0 to 2ⁿ - 1 becomes every combination of true and false. ...inputs spreads the array into separate arguments.

Re-export it from src/index.js:

export * from "./logic.js";

Then try it in demo.js:

import {
  printTable,
  areEquivalent,
  deMorganAnd,
  canEdit,
} from "./src/logic.js";

printTable((a, b) => a && b);
console.log(deMorganAnd);
console.log(areEquivalent((a, b) => a || b, (a, b) => !(!a && !b)));
console.log(
  canEdit({ id: 7, role: "editor" }, { authorId: 7, locked: true, archived: false }),
);
console.log(
  canEdit({ id: 1, role: "admin" }, { authorId: 7, locked: true, archived: false }),
);

Run it:

node demo.js
F F | F
F T | F
T F | F
T T | T
true
true
false
true

The true for deMorganAnd is the program confirming De Morgan's law for you. The next true shows that a || b equals !(!a && !b), which is De Morgan's second law used backwards. The last two lines show that a locked post blocks even its own author, but not an admin.

10. Try It Yourself

  1. Read a table. Call printTable((a, b, c) => a && (b || c)) and count the T results. Then change the function to (a && b) || c and compare. Which one is true more often?
  2. XOR from basics. Write xor(a, b) using only &&, ||, and ! (hint: "one or the other, but not both"). Use areEquivalent to prove it equals (a, b) => a !== b.
  3. Fix a negation. Rewrite !(items.length > 0 && !isLoading) without a leading !, applying De Morgan's law. Check your answer with areEquivalent by turning the comparisons into plain boolean inputs.
  4. Add a rule. Extend canEdit so that a post marked draft: true can be edited only by its author or an admin, even by an editor. Where in the list of early returns should the new rule go, and why?
  5. Short-circuit proof. Write a function loud(label, value) that prints the label and returns the value. Evaluate loud("A", false) && loud("B", true) and loud("A", true) || loud("B", false). Which labels print?
  6. Spot the bug. A teammate rewrote !(age >= 18) as age < 17. For which ages do the two disagree? Use a loop from 0 to 30 to find out.

Boolean logic works on single yes/no answers. Next we look at collections of things instead: Sets and Set Operations shows how AND, OR, and NOT become intersection, union, and difference when the "values" are whole groups of users or tags.

Buy Me a Coffee

Found this tutorial helpful? Consider buying me a coffee to support more free content!