Making a Decision
An if statement runs a block of code only when a condition is true. That single ability is what separates a program from a list of instructions. Without it your code does the same thing every time it runs; with it, your code can respond to what actually happened — what the user typed, what the server returned, whether the cart is empty.
The structure is if, then optionally any number of else if branches, then optionally one else. JavaScript checks them from the top and stops at the first one that is true; the remaining branches are never even evaluated. That ordering is not a detail. If you put the loosest condition first, the tighter ones below it can never run at all — a grade ladder that starts with marks >= 40 will label every good student "Pass" and never reach the distinction branch.
Braces are technically optional when a branch holds exactly one statement, and you should use them anyway. The brace-free form is a well-documented source of bugs: adding a second line later silently leaves it outside the if, so it runs every single time, and your indentation lies to you about it. Two extra characters have prevented a very large number of production incidents.
Conditions combine with && and || from Lesson 3, and invert with !. Where an expression mixes them, add brackets instead of relying on precedence — (a || b) && c and a || (b && c) are different statements, and no reader should have to work out which one you meant.
const marks = 82;
// Checked top to bottom; the first true branch wins, the rest are skipped
if (marks >= 75) {
console.log('Distinction');
} else if (marks >= 60) {
console.log('First class');
} else if (marks >= 40) {
console.log('Pass');
} else {
console.log('Fail');
}
// Order matters. This ladder is broken - nothing ever reaches Distinction
if (marks >= 40) {
console.log('Pass');
} else if (marks >= 75) {
console.log('Distinction'); // unreachable for every possible input
}
// Combine conditions, and bracket the mix
const age = 19;
const hasIdProof = true;
const isStaff = false;
if ((age >= 18 && hasIdProof) || isStaff) {
console.log('Allowed in');
} The Condition Is Converted to a Boolean
Whatever you put inside the brackets is converted to true or false before it is used. You do not have to supply a real boolean, and most real code does not: if (name), if (items.length) and if (user) all work because of this conversion. Knowing the rule exactly is what stops it from surprising you at the worst moment.
There are exactly eight falsy values — false, 0, -0, 0n, '', null, undefined and NaN — and everything else is truthy. The two that most people expect to be on that list and are not are [] and {}. An empty array and an empty object are both truthy, so if (items) is true even when items is completely empty. To ask whether an array has anything in it, test items.length > 0.
The other regular casualty is a legitimate zero. Writing if (quantity) to mean "was a quantity supplied" treats a deliberate 0 as if nothing had been supplied, and on a marks sheet or an order form that is a wrong number on someone's screen. When 0 or an empty string are valid values, test explicitly for absence: if (quantity === undefined), or if (quantity == null) which covers null and undefined together. It is the same distinction as ?? versus || in Lesson 3.
You can convert explicitly when you want a real boolean to store or return: Boolean(value), or the two-character !!value, which is simply ! applied twice. Inside an if this adds nothing, because the conversion happens anyway — so keep it for when you are returning a value or setting a flag that something else will read.
const items = [];
const quantity = 0;
const name = '';
if (items) {
console.log('this runs'); // an empty array is TRUTHY
}
if (items.length > 0) {
console.log('this does not'); // the correct test
}
if (quantity) {
console.log('does not run'); // 0 is falsy - but the 0 was deliberate
}
if (quantity === undefined) {
console.log('does not run either'); // correct: a value really was supplied
}
// null and undefined together, in one comparison
let discount;
if (discount == null) {
console.log('no discount set'); // true for null and for undefined
}
// Explicit conversion, for when you need a real boolean to keep
console.log(Boolean(name)); // false
console.log(!!name); // false
console.log(Boolean([])); // true
console.log(Boolean('0')); // true - a non-empty string, whatever is in it - A thirty-second console experiment fixes this permanently. Type
Boolean([]), thenBoolean(''), thenBoolean('0'), thenBoolean(0). Seeingtrue, false, true, falsecome back makes the rule stick better than any list.
Comparisons That Behave Unexpectedly
Most comparisons do exactly what you expect. A handful do not, and all of them turn up in interviews because they reveal whether you understand what your values actually are rather than what they look like.
NaN is the only value in JavaScript that is not equal to itself. NaN === NaN is false, and so is NaN == NaN. That is not perversity for its own sake: NaN means "this calculation failed", and two separate failures are not the same answer. The practical consequence is that you cannot test for it with === at all. Use Number.isNaN(value), which is true only for the real NaN value.
Decimal arithmetic is not exact. 0.1 + 0.2 produces 0.30000000000000004, so 0.1 + 0.2 === 0.3 is false. This is not a JavaScript defect — it is how binary floating-point numbers behave in nearly every language, because 0.1 has no exact binary representation. When you must compare decimals, compare the difference against a small tolerance. For money, the better answer is to avoid decimals entirely and work in the smallest unit, storing paise as whole numbers rather than rupees with a fractional part.
Two more worth memorising. A chained comparison such as 0 < age < 18 is legal JavaScript and is always true, because it evaluates left to right: 0 < age produces a boolean, and that boolean is then converted to 0 or 1 and compared with 18. Write age > 0 && age < 18 instead. And > and < applied to strings compare character by character, so '10' < '9' is true — which is why you convert form input to a number before comparing it.
// NaN is not equal to itself
const result = Number('abc');
console.log(result); // NaN
console.log(result === NaN); // false - always, for every value
console.log(Number.isNaN(result)); // true - the correct test
// Decimal arithmetic is not exact
console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false
console.log(Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON); // true
// A chained comparison does not mean what it looks like
const age = 25;
console.log(0 < age < 18); // true! (0 < 25) is true, true < 18 is true
console.log(age > 0 && age < 18); // false - what was actually meant
// Strings compare character by character
console.log('10' < '9'); // true - '1' sorts before '9'
console.log(10 < 9); // false - numbers compare as numbers Number.isNaNand the older globalisNaNare not the same function. The global one converts its argument first, soisNaN('abc')istrueeven though a string is notNaN. UseNumber.isNaNand you never have to think about it again.
The Ternary Operator, and When Not to Use It
The ternary operator is a compact if/else that produces a value: condition ? valueIfTrue : valueIfFalse. Because it is an expression rather than a statement, you can put it anywhere a value is expected — on the right of an assignment, inside a template literal, or as an argument to a function.
That is exactly where it earns its place. Assigning one of two values with if/else is clumsy, because you have to declare the variable with let first and then assign it in each branch, which loses the guarantee that const gives a reader. A ternary keeps const and puts the whole decision on one line you can take in at a glance.
It stops earning its place the moment it grows. A nested ternary — one ternary sitting in the false branch of another — is genuinely hard to verify, and a four-level grade ladder written that way takes real effort to read correctly. The rule that works in practice: use a ternary when the entire expression fits comfortably on one line and there is exactly one condition. Anything beyond that, use if/else, a lookup object, or the switch statement from the next lesson.
One more pattern you will meet constantly in front-end work: a ternary inside a template literal. It is how you switch a word, a suffix or a CSS class based on state without having to break out of the string and rebuild it.
const marks = 82;
// The reason to reach for it: choose one of two values and keep const
const status = marks >= 40 ? 'Pass' : 'Fail';
console.log(status); // 'Pass'
// The if/else equivalent needs let and four more lines
let status2;
if (marks >= 40) {
status2 = 'Pass';
} else {
status2 = 'Fail';
}
// Inside a template literal - extremely common in front-end code
const count = 1;
console.log(`You have ${count} item${count === 1 ? '' : 's'}`);
// A CSS class chosen by state
const isActive = true;
const className = `tab ${isActive ? 'tab--active' : ''}`;
console.log(className); // 'tab tab--active'
// Where it stops being readable - prefer if/else or switch here
const grade = marks >= 75 ? 'Distinction'
: marks >= 60 ? 'First class'
: marks >= 40 ? 'Pass'
: 'Fail';
console.log(grade); // works, but nobody enjoys checking it - A ternary always produces a value, so it is the wrong tool when you want to do two different things rather than choose between two values.
isValid ? save() : showError()works, but anif/elsesays what you mean and does not leave a discarded return value behind.
Guard Clauses: Flattening Nested Conditions
Nested if statements grow sideways very fast. At three levels of nesting a reader has to hold three conditions in their head simultaneously just to understand what the innermost line does, and every else ends up far from the if it belongs to. The fix is the guard clause: handle each failure case first and leave the function immediately, so that the main path finishes unindented at the bottom.
Compare the two versions below. They do exactly the same thing for exactly the same inputs. In the nested version the successful path is buried four levels deep. In the guarded version every failure is a single line, written in the order you would check them by hand, and the real work is the last thing in the function with nothing wrapped around it.
Guard clauses also make a missing condition obvious, because each guard stands on its own line instead of being tangled into a chain of else if. This is one of the most reliable readability wins available in the language, it costs nothing, and reviewers notice when it is missing.
A related habit: prefer positive names for your conditions. if (!isNotValid) forces the reader to cancel two negatives before they can carry on. Naming the flag isValid and writing if (!isValid) return says the same thing with far less mental work — and the same applies to isEmpty versus hasItems, where whichever one you check more often is the one to define.
// Nested: the real work is buried, and each else is far from its if
function submitNested(form) {
if (form.name) {
if (form.email.includes('@')) {
if (form.age >= 18) {
return 'Submitted';
} else {
return 'Must be 18 or older';
}
} else {
return 'Invalid email';
}
} else {
return 'Name is required';
}
}
// Guard clauses: failures first, main path last and unindented
function submit(form) {
if (!form.name) return 'Name is required';
if (!form.email.includes('@')) return 'Invalid email';
if (form.age < 18) return 'Must be 18 or older';
return 'Submitted';
}
console.log(submit({ name: 'Ananya', email: 'a@example.com', age: 20 }));
// 'Submitted'
console.log(submit({ name: '', email: 'a@example.com', age: 20 }));
// 'Name is required' - Guard clauses need a function to return from. Inside a loop the same idea uses
continue: check the cases you want to skip at the top of the loop body andcontinuepast them, rather than wrapping the rest of the body in anif.
The Conditional Bugs That Cost Real Time
These are the mistakes that appear most often in real code and in exam papers. What they have in common is that none of them produces an error message. The program runs happily and gives a wrong answer, which is exactly why they are expensive to find.
One equals sign instead of three. if (status = 'paid') assigns 'paid' to status and then uses the string as the condition. A non-empty string is truthy, so the branch always runs — and your variable has been quietly overwritten on the way. A linter catches this instantly; the naked eye reading quickly often does not.
Assuming a form value is a number. input.value is always a string, even on <input type="number">. if (input.value > 100) appears to work because > converts its operands, but if (input.value === 100) is false forever, and if (input.value + 1 > 100) compares '991' against 100. Convert once, at the point where you read the field, and every line after that can trust the type.
=instead of===— assigns instead of comparing, and the branch always runsif (items)to test an empty array —[]is truthy; testitems.lengthif (quantity)when 0 is a valid quantity — test=== undefinedor== nullvalue === NaN— false for everything; useNumber.isNaN(value)0.1 + 0.2 === 0.3— false; compare with a tolerance, or work in whole units0 < age < 18— always true; writeage > 0 && age < 18- Omitting braces, then adding a second line that silently runs every time
- Ordering a grade ladder from the lowest threshold upwards, so the higher branches never run
- When a condition is not doing what you expect, print the raw value and its type before you start guessing:
console.log(value, typeof value). In practice most conditional bugs turn out to be a string sitting where a number was expected.
