Boolean algebra
Contents
The third article in the series, still on conditions. The previous installment
was about their shape — merging ifs, factoring shared decisions, dropping checks that earn nothing. This one reaches for the other lever: the algebra of the conditions themselves — not a textbook tour, just the handful of transformations I lean on in everyday code.
Formal logic
Boolean algebra defines a lot of transformations we can use to simplify expressions and improve readability — double negation, commutation, association, distribution, and the rest. The one I want to highlight here is De Morgan’s laws — the one I reach for most, and the easiest to forget you have.
De Morgan’s laws are very simple — remove the outer negation, negate every term and change && to || or vice versa — they tell us how to distribute negation over conjunction or disjunction:
// for two booleans:
!(a && b) === !a || !b;
!(a || b) === !a && !b;
// generalized to many booleans:
!(a && b && c && ...) === !a || !b || !c || ...;
!(a || b || c || ...) === !a && !b && !c && ...;
Two reactions to head off before the examples land:
- “You’re trading one negation for two.” True with plain booleans, false with comparisons.
!(a <= 1 && b > 100)becomesa > 1 || b <= 100: the negations fold straight into the comparisons. Zero negations, two terms, no extra cost. - “Nobody actually writes
if (!(A && B)).” People do. Sometimes intentionally; more often as an artifact of multiple refactors — classically, a flippedif/elsewhose originalif (A && B) { ... } else { ... }ends upif (!(A && B)) { ... } else { ... }. Common enough to warrant the algebra.
Simple logic
Most uses of De Morgan are unglamorous — small expressions inside if statements. Real conditions usually arrive tangled; the first move is to assign the messy subexpressions to variables and reason about those. What the variables stand for doesn’t matter to the algebra, so here they are just A and B:
// Example #1
A || !(A && B)
// De Morgan
A || (!A || !B)
// associativity
(A || !A) || !B
// tautology: A || !A is true
true || !B
// domination: true || X is true
true
// Example #2
A && !(A && B)
// De Morgan
A && (!A || !B)
// distribution
A && !A || A && !B
// contradiction: A && !A is false
false || A && !B
// identity: false || X is X
A && !B
The first reduces to true outright — the branch ran unconditionally all along, so the test goes away and the guarded code just runs. The second reduces to A && !B, one factor gone. Either way the win isn’t fewer characters; it’s that the shape of the decision becomes legible — the very thing the tangle was hiding.
A worked example
The same toolkit scales. Suppose we’re the carrier, turning each customer’s shipping request into a transport mode. The request tells us how far the package is going (isNear, isFar), whether it’s heavy or bulky (isHeavy), and whether the customer paid for urgent delivery (isUrgent); we answer with useBike, useTruck, useAir, or useTrain. Each mode has its own predicate; useTrain is the fallback — whatever doesn’t fit the others. In plain words:
- For local deliveries (
isNear), we use a bike courier when the item isn’t heavy and the delivery is urgent. Otherwise we use a truck. - For long-distance deliveries (
isFar), we use air when the item isn’t heavy or the delivery is urgent. Otherwise we use a train. - For everything in between (mid-range), we use a truck if the delivery is urgent, a train otherwise.
The natural translation:
const useBike = isNear && !isHeavy && isUrgent;
const useAir = isFar && (!isHeavy || isUrgent);
const useTruck = isNear && !useBike || !isNear && !isFar && isUrgent;
const useTrain = !useBike && !useAir && !useTruck;
useTrain is defined negatively, in terms of the other three — the named-conditions technique from the linearization installment
. We can eliminate that layer entirely by applying Boolean algebra and, specifically, De Morgan’s laws. We arrive at:
const useTrain = !isUrgent && !isNear && (isHeavy || !isFar);
Curious readers can see how it’s done at the end of this post.
The final form is short, direct, and equivalent to “we end up on the train when nothing is urgent, we’re not near, and either the load is heavy or we’re not far either.” A definition you can read.
The most important difference from the named-conditions version: this one reveals the logic of our decisions instead of covering it up.
The function as a truth table
A handy fact worth keeping in your pocket: a pure function of finitely many Boolean variables has only finitely many inputs, so it can be written out in full as a table. However tangled the logic, if it is a pure function of a few Booleans, in the end it is a table. Where the algebra simplifies such a function symbolically, the table just spells it out, row by row — and with few enough arguments, we compute it once and read the answer straight off.
Our use* functions take four Boolean arguments — 16 combinations, fewer after we drop the impossible ones. Distance is three-way (near, far, mid), so isNear and isFar are never both true: 12 rows, not 16.
The function can return the whole use* vector, or just name the chosen mode. Below, the mode and useTrain:
isNear |
isFar |
isHeavy |
isUrgent |
mode | useTrain |
|---|---|---|---|---|---|
| ✓ | truck | ||||
| ✓ | ✓ | bike | |||
| ✓ | ✓ | truck | |||
| ✓ | ✓ | ✓ | truck | ||
| ✓ | air | ||||
| ✓ | ✓ | air | |||
| ✓ | ✓ | train | ✓ | ||
| ✓ | ✓ | ✓ | air | ||
| train | ✓ | ||||
| ✓ | truck | ||||
| ✓ | train | ✓ | |||
| ✓ | ✓ | truck |
Old-school, yes — but don’t knock it. A lookup table is a fine tool, especially for arcane business rules: when they change, you regenerate the table instead of rewriting logic.
And we can trim even that. useTrain is true in only three rows, so the implementation keeps just those: if the inputs match one, true; otherwise false. The table is also the test suite — one case per row, all twelve of them — and because the inputs are finite and fully enumerated, those tests don’t sample the behavior; they cover every case that can occur. That’s rare; worth noticing when you have it.
Summary
Simplification isn’t really about shrinking code — it’s about revealing structure. Named intermediate conditions help when they slice the answer space cleanly (the linearization piece
); they hide it when they don’t (useTrain). The algebra is what tells you which case you’re in.
Reach for any of this when conditions look gnarly or a branch keeps surprising you in review. The cost of working through the algebra is small. The cost of debugging through cached intermediate names next month is not.
Long-tail discipline, not a major win — the same point the whole series keeps making: no individual move buys you much; the compounding does.
The shipping example
Here’s the full derivation that takes useTrain from its negative form to the simple expression above. It’s spelled out one move at a time so it’s easy to follow; in practice you’d drop the obvious dups and contradictions and combine steps — faster than the length suggests. Every step is simple — De Morgan, distributivity, identity and contradiction collapse, and one domain-specific observation (isFar implies !isNear). Where a step fans out into a list of &&-terms, I keep their factors in a fixed order — isUrgent, isNear, isFar, isHeavy — so repeats and contradictions sit side by side, which is what makes the next collapse obvious:
// starting point
const useTrain = !useBike && !useAir && !useTruck;
// unroll useTruck
const useTrain = !useBike && !useAir && !(isNear && !useBike || !isNear && !isFar && isUrgent);
// De Morgan last term
const useTrain = !useBike && !useAir && !(isNear && !useBike) && !(!isNear && !isFar && isUrgent);
// De Morgan last two terms
const useTrain = !useBike && !useAir && (!isNear || useBike) && (isNear || isFar || !isUrgent);
// distributivity over last two terms
const useTrain = !useBike && !useAir && !isNear && isNear ||
!useBike && !useAir && !isNear && isFar ||
!useBike && !useAir && !isUrgent && !isNear ||
!useBike && useBike && !useAir && isNear ||
!useBike && useBike && !useAir && isFar ||
!useBike && useBike && !useAir && !isUrgent;
// collapse: drop the contradictions (!isNear && isNear, and !useBike && useBike),
// then use the domain constraint isFar implies !isNear to fold !isNear && isFar into isFar
const useTrain = !useBike && !useAir && isFar ||
!useBike && !useAir && !isUrgent && !isNear;
// unroll useBike and useAir
const useTrain = !(isNear && isUrgent && !isHeavy) && !(isFar && (isUrgent || !isHeavy)) && isFar ||
!(isNear && isUrgent && !isHeavy) && !(isFar && (isUrgent || !isHeavy)) && !isNear && !isUrgent;
// De Morgan on negated complex terms
const useTrain = (!isNear || !isUrgent || isHeavy) && (!isFar || !(isUrgent || !isHeavy)) && isFar ||
(!isNear || !isUrgent || isHeavy) && (!isFar || !(isUrgent || !isHeavy)) && !isNear && !isUrgent;
// De Morgan again on negated complex terms
const useTrain = (!isNear || !isUrgent || isHeavy) && (!isFar || (!isUrgent && isHeavy)) && isFar ||
(!isNear || !isUrgent || isHeavy) && (!isFar || (!isUrgent && isHeavy)) && !isNear && !isUrgent;
// distributivity
const useTrain =
// 1st half
!isNear && !isFar && isFar ||
!isUrgent && !isNear && isFar && isHeavy ||
!isUrgent && !isFar && isFar ||
!isUrgent && !isUrgent && isFar && isHeavy ||
!isFar && isFar && isHeavy ||
!isUrgent && isFar && isHeavy && isHeavy ||
// 2nd half
!isUrgent && !isNear && !isNear && !isFar ||
!isUrgent && !isUrgent && !isNear && !isNear && isHeavy ||
!isUrgent && !isUrgent && !isNear && !isFar ||
!isUrgent && !isUrgent && !isUrgent && !isNear && isHeavy ||
!isUrgent && !isNear && !isFar && isHeavy ||
!isUrgent && !isUrgent && !isNear && isHeavy && isHeavy;
// collapse logically identical terms
const useTrain =
!isUrgent && isFar && isHeavy ||
!isUrgent && !isNear && !isFar ||
!isUrgent && !isNear && isHeavy ||
!isUrgent && !isNear && !isFar && isHeavy;
// consolidate disjunctions
const useTrain = !isUrgent && !isNear && (isHeavy || !isFar || isHeavy && !isFar);
// collapse logically identical terms
const useTrain = !isUrgent && !isNear && (isHeavy || !isFar);