The Tidy Refactor That Locks Admins Out
De Morgan’s laws, a precedence rule you can’t see, and ten lines of JavaScript that check every case before you merge
This story was drafted with AI assistance. Every code output and truth table in it comes from running the code.
Here’s a line of authorisation code:
if (!(user.isAdmin || (user.isOwner && !doc.locked))) {
return deny();
}In words: deny access unless the user is an admin, or owns the document and it isn’t locked.
Negations wrapped around brackets are hard to read, so someone tidies it up:
if (!user.isAdmin && !user.isOwner || doc.locked) {
return deny();
}It’s shorter, and it reads naturally: not an admin, not the owner, or locked, so deny. The existing tests pass. It gets merged.
And now an admin can’t open a locked document.
Nothing in the test suite covered that combination, because nothing in the old code made it interesting. The refactor changed the behaviour in exactly two of the eight possible combinations of admin, owner and locked, both of them “admin, locked document”, and nowhere else.
Two separate things went wrong. Both are worth knowing by name.
De Morgan’s laws in one minute
The laws are named after the 19th-century mathematician Augustus De Morgan:
!(A && B) is the same as !A || !B
!(A || B) is the same as !A && !B“Not both” means at least one is missing. “Neither” means not this and not that. When you push a ! inside brackets, every operand flips and every operator swaps: && becomes ||, and || becomes &&.
Applied carefully to our condition, one step at a time:
!(isAdmin || (isOwner && !locked))
= !isAdmin && !(isOwner && !locked) // De Morgan on ||
= !isAdmin && (!isOwner || locked) // De Morgan on &&, and !!locked is lockedSo the correct tidy version keeps its brackets:
if (!user.isAdmin && (!user.isOwner || doc.locked)) {
return deny();
}Mistake one: the brackets that weren’t there
The merged version wrote !isAdmin && !isOwner || locked. In JavaScript, as in Python, Java, C and SQL, AND binds tighter than OR, so the language reads it as:
(!isAdmin && !isOwner) || lockedNow locked on its own is enough to deny, whoever you are. Admins included.
Get Muaz’s stories in your inbox
Join Medium for free to get updates from this writer.
Precedence rules aren’t the problem. Invisible precedence is. If a line mixes && and ||, brackets cost nothing and remove the question.
Mistake two: flipping the operands but not the operator
The other classic slip is negating each part and forgetting to swap the operator:
!(a && b) // "not both"
!a && !b // "neither", which is a different ruleThese agree when a and b are both true or both false, and disagree whenever exactly one of them is true. That’s half of all cases, and it’s easy to miss if your tests only try “everything on” and “everything off”.
Don’t reason about it. Enumerate it.
Here’s the habit that would have caught both mistakes. A pure boolean condition with a handful of inputs has a small number of cases: 3 inputs make 8, 10 make 1,024, and 20 make about a million, which the little function below gets through in a few seconds.
So check all of them:
function findDifferences(names, f, g) {
const differences = [];
for (let row = 0; row < 2 ** names.length; row++) {
const input = Object.fromEntries(
names.map((name, i) => [name, Boolean(row & (1 << (names.length - 1 - i)))])
);
if (f(input) !== g(input)) differences.push(input);
}
return differences;
}The bits of each row number are one combination of true and false, with the first name as the highest bit. Feed it the before and after versions:
const before = ({ admin, owner, locked }) => !(admin || (owner && !locked));
const after = ({ admin, owner, locked }) => !admin && !owner || locked;
console.log(findDifferences(['admin', 'owner', 'locked'], before, after));
// [
// { admin: true, owner: false, locked: true },
// { admin: true, owner: true, locked: true }
// ]Two differences, both “an admin and a locked document”. Swap in the correct version and the list comes back empty: the two agree on all eight cases.
Then turn each difference into a unit test. A counterexample is the cheapest regression test you’ll ever write, because the hard part, finding the input that matters, is already done.
Three things that don’t break De Morgan
Short-circuiting. !(a() && b()) and !a() || !b() call b() in exactly the same situations: only when a() returned something truthy. The rewrite keeps side effects in the same order.
Truthy and falsy values. ! turns any value into a boolean first, so the laws hold for JavaScript’s truthiness exactly as they do for true and false.
SQL’s NULL. The laws hold in SQL’s three-valued logic too: NOT (x AND y) and NOT x OR NOT y give the same answer even when one side is NULL. NULL causes plenty of other trouble (a NOT IN list with a NULL in it is the classic), but not this. This guide covers the NULL traps.
When ten lines aren’t enough
The harness works for pure conditions over booleans. Real conditions also compare numbers (age >= 18), test membership (paidPlans.includes(plan)) and count things (at least two of three checks passed). You can still enumerate those, but you have to pick the values yourself, one either side of every threshold, and the bookkeeping grows quickly.
That bookkeeping is what I built Tangibool for. Paste both versions of a condition and it checks every case (cross-checked by a SAT solver), shows you the case where they differ, and saves it as a regression test in one click. It handles thresholds, categories and counts, and it exports to Python or SQL with tests. It’s free in the browser, with no account.
You can run this story’s example in the free equivalence checker, no sign-up: the broken refactor (it finds the admin with a locked document) and the correct one (equivalent on all eight cases).
The short version
- Pushing a
!inside brackets flips every operand and swaps every&&and||. Keep the brackets. - Don’t rely on precedence you can’t see. Add brackets.
- Before you merge a change to a condition, enumerate its cases. Every difference you find is a ready-made test.
More on the free logic reference: De Morgan’s laws, De Morgan in code and how precedence differs across languages.






