Logic and the conditions you write
A support ticket lands: a suspended account owner can still open the billing page. You read the access check. It looks fine. Everyone who reviews it says it looks fine.
It is not fine, and no amount of staring will show you why. The bug is not in the code — it is in the logic, and logic has a notation that makes this kind of bug visible instead of subtle.
- Guess first
- The scenario
- Into notation
- The working
- Prove it
- Where it bites
- Check
The scenario
One line of access control
A billing service decides who may see a company's payment settings. Three facts about the
signed-in person are available: whether they are an admin, whether their
account is suspended, and whether they are the owner of the
company.
The written policy is one sentence: admins and owners can see billing, and suspended accounts can see nothing.
The code that implements it is one line:
if (isAdmin && !isSuspended || isOwner)
Read that line and ask the question the ticket is really asking: is there any combination of those three facts where the code disagrees with the policy?
You could answer it by testing. Sign in as a suspended owner and look. That finds this bug, if you think to try that exact combination — and tells you nothing about the seven other combinations you did not try. There is a better way to answer, and it is the whole of this lesson: stop reading the condition as instructions and start reading it as a formula.
Converting the scenario
From code to notation, in three moves
Move one: name the facts. Each of the three is something that is either true or false, with nothing in between. In logic that is called a proposition, and it gets a single letter:
| Letter | Proposition | In the code |
|---|---|---|
| this person is an admin | isAdmin | |
| this account is suspended | isSuspended | |
| this person is the owner | isOwner |
Move two: translate the operators. Three symbols cover everything a boolean condition can do.
| Code | Symbol | Name | True when |
|---|---|---|---|
&& | conjunction (and) | both sides are true | |
|| | disjunction (or) | at least one side is true | |
! | negation (not) | the thing after it is false |
Move three — and this is the one that carries the bug: put the brackets in.
The code has none, so the language supplies them from its precedence rules, and
&& binds tighter than ||. That is not a quirk of
JavaScript. It is the same convention as arithmetic, where
means :
is logic's multiplication and is its addition.
So the line the machine actually runs is:
and not the reading most people's eyes give it on a quick pass:
Those two formulas are different, and the difference is the ticket. In the first, sits outside everything — being the owner grants access on its own, with suspension never consulted. In the second, being an admin is required no matter what.
The working
Answering the ticket, one line at a time
The question is whether a suspended owner gets in. In notation: what does evaluate to when is true and is true? Take the worst case for access — the person is not an admin — and substitute.
-
Start. The formula, with the brackets the language put in.
-
Substitute the case from the ticket: a suspended owner who is not an admin. false, true, true.
-
Negation. is false.
-
Conjunction. needs both sides true; one false side is enough to make the whole bracket false.
-
Disjunction. needs only one true side. Access granted. The ticket is real, and now you know exactly why: the owner clause sits outside the suspension check, so for an owner the suspension flag is never read at all.
Five lines, each one a rule you could name. That is what "doing the maths" buys you over reading the code harder: every step has a reason, so the conclusion is checkable by someone who does not trust you.
But one substitution answers one question. The ticket asked about a suspended owner. What about the other seven combinations?
The interactive bit
Prove it: the equivalence checker
Here is the move that makes boolean logic different from almost every other kind of maths you will meet: the space of possibilities is finite, and it is small. Three propositions, each true or false, gives combinations. Not "too many to check" — eight.
So you never have to wonder whether two conditions behave the same. Write out all eight rows of each and compare. If every row agrees, they are equivalent, and that is a proof, not a sample. If any row disagrees, you have found the input that separates them, which is also your test case.
Pick any two of the candidate conditions below. The table enumerates every combination and marks the rows where they part company.
admin |
suspended |
owner |
First | Second | Agree? |
|---|
Three comparisons are worth making before you move on.
1 · The precedence trap
Compare the condition as written with the bracketing most people read. They differ on two rows — and in both of them the person is a non-admin owner, which is not a hypothetical: it is the founder who handed the admin role to someone else. Two conditions that read like the same sentence are not the same function.
2 · A rewrite that is genuinely safe
Compare the condition as written with the De Morgan rewrite. Every row agrees, so the rewrite is safe to merge — and you know that without running the application once.
3 · The fix
Compare the condition as written with the policy as written. The rows where they differ are exactly the bug: suspended owners. That difference set is the bug report and the regression test, in one.
Two laws worth memorising
De Morgan, and the contrapositive
De Morgan's laws — how to push a inwards
Sooner or later you need the opposite of a compound condition: an early-return guard, an inverted filter, a negated database predicate. The rule for negating and is not the obvious one.
The negation flips the connective. "Not (both)" is "either one is missing". "Not (either)" is "both are missing". The mistake — and it is the single most common bug in a rewritten condition — is to negate each side and leave the operator alone: is not . The checker above carries exactly that slip as an option — compare the condition as written with the De Morgan slip and watch two rows come apart. Note which two: they are the rows where nobody is the owner, which is to say the ordinary accounts, not the exotic ones. A slip like this does not announce itself on the edge cases you thought to test.
The is doing real work in those lines. It does not mean "is roughly like". It means every row of the truth table matches — the same standard the checker applies.
Implication, and the only rearrangement of it that is valid
The policy sentence was a conditional: if the account is suspended, access is denied. Written out, with for "access granted":
An implication can be turned around exactly one way and stay true. Swap the two sides and negate both, and you get the contrapositive, which is always equivalent to the original:
In words: if access was granted, the account was not suspended. And that is a far more useful sentence than the one the policy started with, because it is directly checkable — it names a property that must hold of every row where the condition came out true. Scan the table above for a row where the condition is true and is true, and if you find one the policy is violated. You will find one.
The two rearrangements that are not valid are worth naming, because both turn up in code review dressed as reasoning:
- The converse, — "access was denied, so they must be suspended". Not implied: access can be denied for being neither admin nor owner.
- The inverse, — "not suspended, so they get in". Not implied either, and it is how a permission check quietly becomes a permission grant.
Where this bites
The same maths, four places you have already been
-
Databases
The rows your
WHEREclause silently dropsSQL is not two-valued. A comparison against
NULLisUNKNOWN, not false, andWHEREkeeps only rows that are true. Sostatus != 'closed'drops every row where status isNULL— they were never closed, and they are not in your report. The boolean laws above still hold, but in a three-valued logic, and the truth table has nine rows instead of four. -
Testing
How many tests a condition actually needs
Full coverage of a condition with boolean inputs is cases: 8 here, 1,024 at ten inputs. That exponential is why "test every combination" stops being an option almost immediately, and why techniques like MC/DC exist — they pick the subset that would still catch a change in each input independently.
-
Refactoring
Inverting a guard without changing behaviour
Turning a nested
ifinto an early return means negating its condition, which is a De Morgan application every time. Get the flip wrong and you have written a different function that passes most of your tests. This is exactly the rewrite the checker above is for. -
Performance and safety
Why the order of
&&matters even though the logic says it does notand are equivalent — every row agrees. But code short-circuits:
a && bnever evaluatesbwhenais false. So the logic is symmetric and the execution is not, which is whyuser != null && user.isAdminis safe and the reverse throws. Knowing which properties the maths guarantees, and which it does not, is the whole skill.
The one thing to keep
A condition is a formula, and formulas can be proved equal
You do not have to believe that two conditions behave the same, and you do not have to test your way to confidence. With booleans there are rows, and for the handful of inputs a real condition has, writing them all out is a minute's work that settles the question completely.
The next lesson takes the same move — replace a vague description with a structure that can be reasoned about — and points it at data instead of control flow: sets, relations, and why a SQL join is an operation you already know.