Logic and mathematical thinking Foundation
Conditional reasoning
Turn written business rules into exact conditions, invert and simplify them safely with De Morgan's laws, and restructure them as guard clauses that explain every refusal.
BY THE END OF THIS LESSON YOU CAN
- Translate a business rule written in English, including "unless", "only if" and "neither ... nor", into a condition
- Invert a condition correctly with De Morgan's laws, including comparisons such as >=
- Simplify a condition and prove the simplified version equivalent with a loop
- Rewrite an AND-rule as guard clauses that each return a specific reason
- Spot the classic condition bugs: always-true ORs, wrong negations and misplaced NOTs
"Why was my withdrawal refused?"
The bank from Boolean logic has fixed its cash machine. The rule is now written carefully:
A customer may withdraw an amount only if the account is active, the account is not locked, the balance covers the amount, and the amount does not exceed what is left of today's limit.
The code matches the rule exactly:
const account = { active: true, locked: false, balance: 40000, withdrawnToday: 90000 };
const dailyLimit = 100000;
const amount = 20000;
const allowed =
account.active &&
!account.locked &&
account.balance >= amount &&
account.withdrawnToday + amount <= dailyLimit;
console.log(allowed);
node rule.js and of the browser terminalfalse
The braces make an object: a group of named values, called properties. account.balance reads the balance property. You will study objects in their own lesson.
The answer is correct, and useless to the customer. The machine says "refused", the customer phones the bank, and the support agent has to work out which of the four parts failed. Here the balance is fine; the daily limit is the problem (₦90,000 already taken, ₦20,000 more would pass ₦100,000).
To tell the customer why, the program must reason about the opposite of the rule: "when is a withdrawal refused, and for which reason?" Getting the opposite of a condition right is surprisingly hard, and it is where many real bugs live. This lesson gives you the tools: translating rules precisely, De Morgan's laws for inverting them, simplification you can prove, and guard clauses that turn an inverted rule into clear code.
From sentences to conditions
Business rules arrive as sentences. Before you write any code, split the sentence into atomic propositions (true/false statements, as in the previous lesson), then find the words that join them. Some joining words map directly onto operators; others need care:
| Words in the rule | Logic | Example |
|---|---|---|
| A and B; A, B and C; both A and B | a && b | active and not locked |
| A or B; at least one of | a || b | admin or owner |
| not A; A is false; no A | !a | not locked |
| A but B | a && b | free delivery, but only in Lagos |
| neither A nor B | !a && !b | neither suspended nor banned |
| A unless B | a && !b | ship today unless it is a holiday |
| A only if B | A needs B: if (!b) then no A | withdraw only if active |
| either A or B (but not both) | a !== b | card or transfer, not both |
"But" sounds like a contrast, yet logically it is just AND. "Unless" means "if not": "ship today unless it is a holiday" is "ship today if it is not a holiday".
// "Orders placed before 2 pm ship today, unless it is a public holiday."
const hourPlaced = 11;
const isHoliday = true;
const shipsToday = hourPlaced < 14 && !isHoliday;
console.log("ships today:", shipsToday);
// "Neither suspended nor banned users may post."
const isSuspended = false;
const isBanned = false;
console.log("may post:", !isSuspended && !isBanned);
node unless.js and of the browser terminalships today: false may post: true
"Before 2 pm" uses a 24-hour clock: 14 is 2 pm, and "before" does not include 14 itself, so the operator is <.
"Only if" is a requirement, not a promise
"You may withdraw only if the account is active" says an inactive account can never withdraw. It does not say every active account may withdraw; other conditions may also apply. A condition that must hold for something to happen is called necessary. A condition that on its own guarantees it is called sufficient. In an AND-rule, every part is necessary and no single part is sufficient. In an OR-rule, every part is sufficient and no single part is necessary.
const isActive = true;
const isLocked = true;
// "active" is necessary, not sufficient: this account is active and still refused
console.log(isActive && !isLocked);
const isAdmin = true;
const isOwner = false;
// in an OR-rule, "admin" alone is sufficient
console.log(isAdmin || isOwner);
node necessary.js and of the browser terminalfalse true
Comparisons: watch the boundary
Numbers in rules come with boundary words, and each maps to one operator. Getting one wrong affects only the single value on the boundary, which is exactly why it slips through testing:
| Words | Operator | Does the boundary count? |
|---|---|---|
| at least 18, 18 or more, no less than 18 | age >= 18 | yes, 18 passes |
| more than 18, over 18, above 18 | age > 18 | no, 18 fails |
| at most ₦100,000, up to, no more than | total <= 100000 | yes |
| less than, under, below | total < 100000 | no |
TIP
When a rule says "over 18" and you are not sure the author meant to exclude 18 itself, ask. This is one of the most common questions a developer should send back to the business, and it is much cheaper to ask than to fix later.Inverting a condition
You often need the exact opposite of a rule: when to refuse, when to show an error, when to skip. The safe way to invert any condition is to wrap it in !( … ). That is always correct, but hard to read. The trouble starts when you try to push the NOT inside.
Here is the mistake almost everyone makes once. The rule for free delivery is "big order and in Lagos". A developer wants the opposite, "no free delivery", and flips each part:
const bigOrder = true;
const inLagos = false;
const free = bigOrder && inLagos;
const noFreeWrong = !bigOrder && !inLagos; // flipped each part, kept &&
const noFreeRight = !(bigOrder && inLagos);
console.log("free:", free);
console.log("wrong opposite:", noFreeWrong);
console.log("right opposite:", noFreeRight);
node wrong-flip.js and of the browser terminalfree: false wrong opposite: false right opposite: true
The order is big but outside Lagos. It does not get free delivery, and it also does not satisfy "not big and not in Lagos". Both answers are false, which is impossible for a condition and its opposite: exactly one of them must be true. Flipping each part is not enough; the operator must flip too.
De Morgan's laws
Augustus De Morgan, another nineteenth-century mathematician, wrote down the two rules for pushing a NOT inside:
!(a && b) is the same as !a || !b !(a || b) is the same as !a && !b
In words: negate each part and swap AND with OR. "Not (big and in Lagos)" is "not big, or not in Lagos": failing either requirement is enough to lose free delivery. And "not (admin or owner)" is "not admin and not owner": to be refused you must fail both ways in.
You do not have to trust this. Two inputs have four combinations, so a loop proves it:
let holds = true;
for (const a of [false, true]) {
for (const b of [false, true]) {
const law1 = !(a && b) === (!a || !b);
const law2 = !(a || b) === (!a && !b);
console.log(a, b, law1, law2);
if (!law1 || !law2) holds = false;
}
}
console.log("De Morgan holds in every row:", holds);
node de-morgan.js and of the browser terminalfalse false true true false true true true true false true true true true true true De Morgan holds in every row: true
The laws extend to any number of parts: !(a && b && c) is !a || !b || !c. The opposite of a checklist is "at least one box is unticked".
Negating comparisons
Comparisons have opposites too, and the boundary moves to the other side:
| Condition | Its opposite | Not the opposite |
|---|---|---|
balance >= amount | balance < amount | balance <= amount |
age > 17 | age <= 17 | age < 17 |
status === "active" | status !== "active" |
The opposite of "at least" is "less than", not "at most". Check the boundary: when balance equals amount, the withdrawal is allowed, so "refused" must be false there. balance < amount gives false; balance <= amount would wrongly refuse.
const amount = 5000;
for (const balance of [4999, 5000, 5001]) {
const allowed = balance >= amount;
console.log(balance, "allowed:", allowed, "| refused (<):", balance < amount, "| refused (<=):", balance <= amount);
}
node negate-compare.js and of the browser terminal4999 allowed: false | refused (<): true | refused (<=): true 5000 allowed: true | refused (<): false | refused (<=): true 5001 allowed: true | refused (<): false | refused (<=): false
At 5000 the account is both "allowed" and "refused (<=)": the wrong negation contradicts the rule on exactly one value. The three test values, one below the boundary, the boundary itself, and one above, are the standard way to test any comparison.
Three classic condition bugs
The always-true OR
A shop allows cancelling an order that is not shipped and not delivered. A developer writes "status is not shipped or not delivered":
for (const status of ["pending", "shipped", "delivered"]) {
const buggy = status !== "shipped" || status !== "delivered";
const fixed = status !== "shipped" && status !== "delivered";
console.log(status, "buggy:", buggy, "fixed:", fixed);
}
node always-true.js and of the browser terminalpending buggy: true fixed: true shipped buggy: true fixed: false delivered buggy: true fixed: false
The buggy version is always true. A status cannot be both "shipped" and "delivered", so it always differs from at least one of them. The rule "neither shipped nor delivered" is !(shipped || delivered), which De Morgan turns into !shipped && !delivered: AND, not OR. Whenever you see x !== A || x !== B, it is a bug.
The comparison that is not a comparison
const role = "customer";
if (role === "admin" || "manager") {
console.log("buggy: access granted to", role);
}
if (role === "admin" || role === "manager") {
console.log("fixed: access granted to", role);
} else {
console.log("fixed: access refused to", role);
}
node or-string.js and of the browser terminalbuggy: access granted to customer fixed: access refused to customer
English lets you say "role is admin or manager"; JavaScript does not. role === "admin" || "manager" is read as (role === "admin") || "manager", and the non-empty string "manager" is truthy, so everyone gets in. Each side of || must be a complete comparison.
The misplaced NOT
! binds tighter than anything else, so !account.locked && account.active means "(not locked) and active", while !(account.locked && account.active) means "not (locked and active)". These are different rules. When a NOT applies to a group, the parentheses are mandatory.
Simplifying conditions, and proving it
Conditions grow messy as rules change: someone adds a check, someone else adds a special case. Simpler conditions are easier to read and harder to get wrong. Here are the simplifications you will use most:
| Messy | Simpler | Why |
|---|---|---|
isActive === true | isActive | it already is a boolean |
!(!isActive) | isActive | double negation |
!(age < 18) | age >= 18 | negated comparison |
age >= 18 && age >= 21 | age >= 21 | the stricter part implies the other |
isAdmin || (isAdmin && isOwner) | isAdmin | absorption: the group adds nothing |
(a && b) || (a && c) | a && (b || c) | factor out the shared part |
if (x) { return true; } else { return false; } | return x; | the condition is the answer |
isActive === true is only the same as isActive when the value is a real boolean. With a possibly missing value, the explicit comparison is deliberate, as you saw with unknown values in the previous lesson. Simplify only what you know.
Proving a simplification with a loop
A simplification is a refactor: the behaviour must not change. For boolean inputs you can prove it by comparing the old and new condition on every row. Write the check once, as a function that takes the two conditions as its inputs. In JavaScript a function is a value, so you can pass it to another function like any number or string:
function sameOnEveryRow(oldRule, newRule) {
for (const a of [false, true]) {
for (const b of [false, true]) {
for (const c of [false, true]) {
if (oldRule(a, b, c) !== newRule(a, b, c)) {
console.log("differs at", a, b, c);
return false;
}
}
}
}
return true;
}
function oldDiscount(isMember, hasCoupon, isSale) {
return (isMember && hasCoupon) || (isMember && isSale) || (isMember && hasCoupon && isSale);
}
function newDiscount(isMember, hasCoupon, isSale) {
return isMember && (hasCoupon || isSale);
}
function wrongDiscount(isMember, hasCoupon, isSale) {
return isMember && hasCoupon || isSale;
}
console.log("new matches old:", sameOnEveryRow(oldDiscount, newDiscount));
console.log("wrong matches old:", sameOnEveryRow(oldDiscount, wrongDiscount));
node equivalent.js and of the browser terminalnew matches old: true differs at false false true wrong matches old: false
The old discount rule has three groups, all needing isMember. Factoring out isMember gives the short version, and the loop confirms that all eight rows agree. The "wrong" attempt forgot parentheses, and the checker immediately names a row where it differs: a non-member during a sale would get the member discount.
return inside the loops ends the whole function at once, so the checker stops at the first row that differs. If it gets through every row, the two rules are equivalent: same answer for every input.
For conditions on numbers, such as age >= 18 && age >= 21, you cannot loop over every possible number. Loop over the boundary values instead: each boundary, one below it and one above it, plus one value far away on each side. Those are the only places where two comparisons can start to disagree.
const ages = [0, 17, 18, 19, 20, 21, 22, 90];
let same = true;
for (const age of ages) {
const oldRule = age >= 18 && age >= 21;
const newRule = age >= 21;
if (oldRule !== newRule) same = false;
}
console.log("same on every boundary value:", same);
const wrongRule = (age) => age > 21; // "over 21" instead of "21 or more"
for (const age of ages) {
if ((age >= 21) !== wrongRule(age)) console.log("differs at age", age);
}
node boundaries.js and of the browser terminalsame on every boundary value: true differs at age 21
The boundary list catches the classic slip of > for >= at exactly one value, 21. A list of "normal" ages like 30, 40 and 50 would never find it. (age) => age > 21 is a short arrow function: it takes age and returns the comparison.
Guard clauses: the inverted rule as code
Back to the refused withdrawal. The rule is an AND of four parts:
allowed = active && !locked && balance >= amount && withdrawnToday + amount <= limit
De Morgan tells you its opposite is an OR of the four negated parts:
refused = !active || locked || balance < amount || withdrawnToday + amount > limit
Each part of that OR is one reason to refuse. That is exactly the shape of guard clauses, which you met in Making decisions: a series of early returns at the top of a function, each checking one bad case. When a guard's condition is true, the function stops and returns its reason. Only if every guard passes does the function reach the last line, the "yes".
REASON IT OUT
Which order should the guards go in?
You are about to write checkWithdrawal(account, amount, dailyLimit) with one guard per reason. Before writing it, think:
- What inputs could arrive that the business rule never mentions? Think about the amount itself.
- Does the order of the guards change the answer (allowed or refused)? Does it change the reason?
- A locked account belongs to someone under a fraud investigation. Should the machine tell them "insufficient funds"?
- Which reasons might a customer be able to fix themselves?
Show the reasoning
Inputs the rule forgot. The amount could be zero, negative, or not a whole number of naira. A negative withdrawal would add money. None of these is in the business sentence, but all of them must be refused, so the very first guard checks that the amount is a positive whole number.
Order and the answer. The final allowed/refused answer does not depend on the order: an OR is true if any part is true, whatever the order. But the reason does. An account that is locked and also short of money will get whichever reason is checked first.
Order and safety. Check the account's state (active, not locked) before anything about money. A locked account should hear "account locked", not details about its balance: revealing the balance to someone who may not own the card leaks information. Security checks go first; checks about the specific request go after.
Fixable reasons. "Insufficient funds" and "over the daily limit" are things the customer can fix by asking for less, so those messages are worth making specific. "Account locked" needs the bank, so it should point the customer to support.
function checkWithdrawal(account, amount, dailyLimit) {
if (!Number.isInteger(amount) || amount <= 0) return "refused: invalid amount";
if (!account.active) return "refused: account not active";
if (account.locked) return "refused: account locked, contact support";
if (account.balance < amount) return "refused: insufficient funds";
if (account.withdrawnToday + amount > dailyLimit) return "refused: over daily limit";
return "allowed";
}
const limit = 100000;
const ada = { active: true, locked: false, balance: 40000, withdrawnToday: 90000 };
const bola = { active: true, locked: true, balance: 500, withdrawnToday: 0 };
console.log(checkWithdrawal(ada, 20000, limit));
console.log(checkWithdrawal(ada, 10000, limit));
console.log(checkWithdrawal(bola, 20000, limit));
console.log(checkWithdrawal(ada, -5000, limit));
console.log(checkWithdrawal(ada, 2.5, limit));
node check-withdrawal.js and of the browser terminalrefused: over daily limit allowed refused: account locked, contact support refused: invalid amount refused: invalid amount
Number.isInteger(amount) is a built-in check that is true only for whole numbers. Each guard is one negated part of the rule. Compare the guards with the refused line above: !active, locked, balance < amount, withdrawnToday + amount > limit. De Morgan did the design work; the guards just give each part a name and a message.
Bola's account is both locked and short of money, and she hears only "locked", because that guard comes first. Ada's second attempt of ₦10,000 brings her exactly to the limit (₦100,000), which is allowed because the rule says "does not exceed". The guard uses >, the correct negation of <=.
TIP
Guard clauses fail closed: the function returns "allowed" only if it survives every check. If you add a new rule, you add a new guard, and forgetting one cannot accidentally allow something the old guards refused.Testing the checker
Two kinds of test catch almost every condition bug:
- One test per reason. For each guard, an input that fails that guard and passes all earlier ones, so you see its exact message.
- Boundary tests for every comparison: just below, exactly on, and just above.
A test table lists the input and the expected result; a loop compares:
function checkWithdrawal(account, amount, dailyLimit) {
if (!Number.isInteger(amount) || amount <= 0) return "refused: invalid amount";
if (!account.active) return "refused: account not active";
if (account.locked) return "refused: account locked, contact support";
if (account.balance < amount) return "refused: insufficient funds";
if (account.withdrawnToday + amount > dailyLimit) return "refused: over daily limit";
return "allowed";
}
const ok = { active: true, locked: false, balance: 50000, withdrawnToday: 60000 };
const limit = 100000;
const cases = [
["zero amount", ok, 0, "refused: invalid amount"],
["inactive", { ...ok, active: false }, 1000, "refused: account not active"],
["locked", { ...ok, locked: true }, 1000, "refused: account locked, contact support"],
["balance - 1", ok, 50001, "refused: insufficient funds"],
["balance exact", { ...ok, withdrawnToday: 0 }, 50000, "allowed"],
["limit exact", ok, 40000, "allowed"],
["limit + 1", ok, 40001, "refused: over daily limit"],
];
let failures = 0;
for (const [name, account, amount, expected] of cases) {
const actual = checkWithdrawal(account, amount, limit);
if (actual !== expected) failures = failures + 1;
console.log(actual === expected ? "PASS" : "FAIL", name, "->", actual);
}
console.log(failures === 0 ? "all passed" : `${failures} failed`);
node test-withdrawal.js and of the browser terminalPASS zero amount -> refused: invalid amount PASS inactive -> refused: account not active PASS locked -> refused: account locked, contact support PASS balance - 1 -> refused: insufficient funds PASS balance exact -> allowed PASS limit exact -> allowed PASS limit + 1 -> refused: over daily limit all passed
{ ...ok, active: false } makes a copy of the ok account with one property changed. The three dots are called spread; Objects and JSON explains it. Changing one property at a time is what makes each test fail exactly one guard.
Try it: change account.balance < amount to account.balance <= amount in the checker and run it again. Only "balance exact" fails, the boundary test. Without it, that bug would ship.
Production concerns
- One source of truth. If the web app, the mobile app and the cash machine each write their own version of the withdrawal rule, they will drift apart. Keep the rule in one function on the server and call it from everywhere.
- Reasons as codes. Real systems return a short code (
"DAILY_LIMIT") as well as a message, so apps can translate the message and support staff can search logs for it. - Messages must not leak. Decide per reason what the user may learn. "Account locked, contact support" is fine; "account locked due to fraud report #4411" is not. Log the detailed reason for staff, show the safe one to the user.
- Changing a rule is a refactor with tests. Before you simplify or reorder conditions in a money or permission check, prove equivalence with a loop or a test table, as above.
Practice
TRY IT YOURSELF
Invert the free delivery rule
Free delivery: (bigOrder || isPremium) && inLagos. Use De Morgan to write noFreeDelivery without an outer !( … ), then prove with nested loops that it is the exact opposite in all 8 rows.
Show a solution
The outer operator is AND, so the opposite is an OR of the negated parts: !(bigOrder || isPremium) || !inLagos. The first part is itself a negated OR, which becomes an AND: (!bigOrder && !isPremium) || !inLagos.
function freeDelivery(bigOrder, isPremium, inLagos) {
return (bigOrder || isPremium) && inLagos;
}
function noFreeDelivery(bigOrder, isPremium, inLagos) {
return (!bigOrder && !isPremium) || !inLagos;
}
let opposite = true;
for (const bigOrder of [false, true]) {
for (const isPremium of [false, true]) {
for (const inLagos of [false, true]) {
if (freeDelivery(bigOrder, isPremium, inLagos) === noFreeDelivery(bigOrder, isPremium, inLagos)) {
opposite = false;
}
}
}
}
console.log("exact opposite in all 8 rows:", opposite);
node invert-delivery.js and of the browser terminalexact opposite in all 8 rows: true
A condition and its opposite must disagree on every row, so the test looks for any row where they are equal. In words: no free delivery when the order is small and the customer is not premium, or when the address is outside Lagos.
TRY IT YOURSELF
Fix the cancellation rule
This function should allow cancelling only when the order is neither shipped nor delivered, but it lets every order be cancelled. Find the bug, fix it, and test it with all three statuses.
function canCancel(status) {
return status !== "shipped" || status !== "delivered";
}
console.log(canCancel("pending"), canCancel("shipped"), canCancel("delivered"));
node cancel-bug.js and of the browser terminaltrue true true
Show a solution
function canCancel(status) {
return status !== "shipped" && status !== "delivered";
}
console.log(canCancel("pending"), canCancel("shipped"), canCancel("delivered"));
node cancel-fixed.js and of the browser terminaltrue false false
"Neither shipped nor delivered" is !(shipped || delivered), and De Morgan turns it into !shipped && !delivered. With ||, every status differs from at least one of the two, so the condition was always true.
TRY IT YOURSELF
Guard a room booking
A hotel lets a guest book a room only if the guest is logged in, the room exists, the number of nights is a whole number from 1 to 30, and the room is not already booked. Write checkBooking(guest, room, nights) with guard clauses in a sensible order, and test each reason plus the 1 and 30 night boundaries.
Show a solution
function checkBooking(guest, room, nights) {
if (!guest) return "refused: please log in";
if (!room) return "refused: no such room";
if (!Number.isInteger(nights) || nights < 1 || nights > 30) return "refused: nights must be 1 to 30";
if (room.booked) return "refused: room already booked";
return "booked";
}
const guest = { name: "Chidi" };
const room = { number: 12, booked: false };
console.log(checkBooking(null, room, 2));
console.log(checkBooking(guest, undefined, 2));
console.log(checkBooking(guest, room, 0));
console.log(checkBooking(guest, room, 1));
console.log(checkBooking(guest, room, 30));
console.log(checkBooking(guest, room, 31));
console.log(checkBooking(guest, { number: 14, booked: true }, 2));
node booking.js and of the browser terminalrefused: please log in refused: no such room refused: nights must be 1 to 30 booked booked refused: nights must be 1 to 30 refused: room already booked
"From 1 to 30" is nights >= 1 && nights <= 30; its opposite, by De Morgan, is nights < 1 || nights > 30. The login and existence checks come first, because later guards read properties of room, and reading room.booked when room is undefined would crash.
Recap
- Translate rules word by word: "but" is AND, "unless" is AND NOT, "neither ... nor" is NOT ... AND NOT, "only if" states a necessary condition. Ask when a boundary ("over 18") is unclear.
- De Morgan:
!(a && b)is!a || !b, and!(a || b)is!a && !b. Negate each part and swap the operator. - The opposite of
>=is<, and of<=is>. Test comparisons below, on and above the boundary. x !== A || x !== Bis always true, andx === A || "B"is always truthy. Both are bugs.- Simplify conditions, then prove the new version equivalent by comparing it with the old on every row.
- An AND-rule inverted is an OR of reasons; guard clauses check each reason in turn, security checks first, and return "yes" only at the end.
Next: Sets, where conditions about single values become questions about whole groups: which permissions a user has, which tags two products share, and which items are duplicates.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.