Control Flow in JavaScript: Making Your Code Think
A practical guide to if, else if, switch, ternary, and short-circuit evaluation with code examples and patterns that actually show up in production.

Every program you write needs to make decisions. Should this user see the dashboard or the login page? Is the entered password long enough? Did the order total cross the free-shipping threshold? These are not abstract questions, they are decisions your code has to make, and the mechanism that handles all of them is called control flow.
If you have been writing JavaScript for a while, you have already used if statements without thinking too hard about them. But there is more going on under the hood than it might seem, and understanding the full picture including if-else, else if ladders, switch, ternary operators, and short-circuit evaluation
What Control Flow Actually Means
By default, JavaScript runs your code line by line, top to bottom. Control flow is simply the ability to change that order based on conditions. You are telling the program: "do not just blindly execute everything, instead look at the situation and decide what to run."
Think of it like a GPS. A GPS does not just give you one fixed route. It says: if there is traffic on the highway, take the side road. If you missed the turn, recalculate. That conditional reasoning is control flow.
In JavaScript, the main tools for this are:
ifif-elseelse ifchainsswitchTernary operator
Short-circuit evaluation with
&&and||
Truthy and Falsy: The Foundation
Before looking at any conditional structure, you need to understand something specific to JavaScript: conditions do not just accept true or false. They accept any value, and JavaScript quietly converts that value into a boolean.
Some values are falsy, meaning JavaScript treats them as false inside a condition:
false0""(empty string)nullundefinedNaN
Everything else is truthy. Every non-zero number, every non-empty string, every object, every array all of them evaluate as true inside a condition.
This matters constantly in real code. When you see something like this:
const username = "";
if (username) {
console.log("Welcome, " + username);
} else {
console.log("Please enter a username.");
}
// Output: Please enter a username.
The condition is not checking if username === true. It is checking if username is truthy. An empty string is falsy, so the else branch runs. This is not a coincidence or a shortcut — it is a core JavaScript behavior that experienced developers rely on deliberately.
A practical example: checking if a user object exists before accessing its properties.
function greetUser(user) {
if (user) {
console.log("Hello, " + user.name);
} else {
console.log("No user found.");
}
}
greetUser({ name: "Sarah" }); // Hello, Sarah
greetUser(null); // No user found.
greetUser(undefined); // No user found.
Both null and undefined are falsy, so both trigger the else branch without any explicit comparison needed.
One thing to watch out for: 0 is falsy. If you are checking whether a number has a valid value, if (count) will fail when count is 0, even though 0 is a perfectly valid number. In those cases, be explicit: if (count !== null && count !== undefined) or use the ?? operator, covered later in this post.
The if Statement
The if statement is the simplest form of decision-making. It says: run this block of code only if this condition is true.
const userAge = 20;
if (userAge >= 18) {
console.log("Access granted. Welcome.");
}
If userAge is 20, the condition userAge >= 18 evaluates to true, so the message prints. If the age were 15, JavaScript would evaluate the condition, find it false, and skip the block entirely without throwing an error.
One thing to keep in mind: if statements do not require a paired else. They work perfectly fine on their own when you only want to act on the true case and do nothing otherwise.
function applyDiscount(cartTotal) {
let discount = 0;
if (cartTotal > 1000) {
discount = 0.15;
}
return cartTotal - cartTotal * discount;
}
console.log(applyDiscount(1200)); // 1020
console.log(applyDiscount(800)); // 800
Here, if the total is under 1000, discount stays 0 and no special logic runs. Clean and readable.
The if-else Statement
What happens when you need to handle both outcomes? That is where else comes in. It is the fallback that runs when the condition is false.
const marks = 72;
if (marks >= 50) {
console.log("Result: Pass");
} else {
console.log("Result: Fail");
}
The structure is straightforward: one condition, two possible paths. Exactly one of them will always run.
A real-world example would be authentication:
function checkLogin(username, password) {
const isValid = username === "admin" && password === "secure123";
if (isValid) {
return { success: true, message: "Login successful" };
} else {
return { success: false, message: "Invalid credentials" };
}
}
const result = checkLogin("admin", "wrongpassword");
console.log(result.message); // "Invalid credentials"
Notice how the function always returns something meaningful. Both paths are handled. That is a sign of reliable code.
The else if Ladder
Real applications rarely deal with just two outcomes. A student's grade is not pass or fail, it is A, B, C, D, or F. A user's role might be admin, editor, viewer, or guest. For these situations, you chain conditions using else if.
const marks = 85;
if (marks >= 90) {
console.log("Grade: A");
} else if (marks >= 80) {
console.log("Grade: B");
} else if (marks >= 70) {
console.log("Grade: C");
} else if (marks >= 60) {
console.log("Grade: D");
} else {
console.log("Grade: F");
}
// Output: Grade: B
JavaScript evaluates each condition from top to bottom and stops at the first one that is true. So with a score of 85, it checks if 85 >= 90 (false), then checks 85 >= 80 (true), runs that block, and skips everything below it.
This top-to-bottom short-circuit is important to understand. Put your most specific or most likely conditions first when performance matters. Put broader catch-all conditions toward the end.
Here is a more practical example, setting API rate limits based on user tier:
function getRateLimit(userTier) {
if (userTier === "enterprise") {
return 10000;
} else if (userTier === "pro") {
return 2000;
} else if (userTier === "basic") {
return 500;
} else {
return 100; // free tier default
}
}
console.log(getRateLimit("pro")); // 2000
console.log(getRateLimit("unknown")); // 100
Clean, easy to read, and easy to extend later when you add a new tier.
Nested if Statements: What They Are and Why to Avoid Them
You can place an if statement inside another if statement, and technically it works. But it tends to become a problem quickly.
// This works, but it is heading in the wrong direction
function checkAccess(user) {
if (user) {
if (user.isActive) {
if (user.role === "admin") {
return "Full access";
} else {
return "Limited access";
}
} else {
return "Account inactive";
}
} else {
return "No user found";
}
}
Three levels deep and this is already hard to follow. Every new nesting level pushes the actual logic further to the right and makes it harder to trace what conditions need to be true for any given line to run.
The fix is the guard clause pattern, covered in its own section below. Just know for now that deeply nested conditionals are a code smell, and there is almost always a cleaner way to write the same logic.
The switch Statement
The switch statement is designed for a specific situation: when you are comparing one value against many possible exact matches. It checks a single expression and then jumps to the matching case.
const day = 3;
switch (day) {
case 1:
console.log("Monday");
break;
case 2:
console.log("Tuesday");
break;
case 3:
console.log("Wednesday");
break;
case 4:
console.log("Thursday");
break;
case 5:
console.log("Friday");
break;
case 6:
console.log("Saturday");
break;
case 7:
console.log("Sunday");
break;
default:
console.log("Invalid day number");
}
// Output: Wednesday
The value of day is compared against each case. When a match is found, JavaScript runs that block. The break keyword then tells JavaScript to exit the switch entirely.
switch Uses Strict Equality
One detail that trips a lot of people up: switch compares values using strict equality (===), not loose equality (==). This means the type matters.
const input = "3"; // a string
switch (input) {
case 3: // a number — this will NOT match
console.log("It is three");
break;
case "3": // a string — this WILL match
console.log("It is the string three");
break;
}
// Output: It is the string three
If your cases are numbers but your value comes from a form input or a URL parameter (which are always strings in JavaScript), you need to convert the type before passing it into the switch, or your cases will silently fail to match.
const dayNumber = parseInt("3", 10); // convert string to number first
switch (dayNumber) {
case 3:
console.log("Wednesday");
break;
}
What Happens Without break?
This is where a lot of developers get tripped up. If you forget break, JavaScript does not stop at the matching case. It "falls through" into the next case and runs that too, and the one after, until it hits a break or the end of the switch. This is called fall-through behavior.
const day = 2;
switch (day) {
case 1:
console.log("Monday");
case 2:
console.log("Tuesday");
case 3:
console.log("Wednesday");
break;
case 4:
console.log("Thursday");
break;
}
// Output:
// Tuesday
// Wednesday
Even though day is 2, both "Tuesday" and "Wednesday" print because there is no break after case 2. JavaScript matched case 2 and then kept executing until it hit the break on case 3.
Most of the time this is a bug, not a feature. Always add break unless you intentionally want fall-through.
That said, intentional fall-through is occasionally useful. Here is an example where weekdays and weekends share logic:
function getDayType(day) {
switch (day) {
case "Monday":
case "Tuesday":
case "Wednesday":
case "Thursday":
case "Friday":
return "Weekday";
case "Saturday":
case "Sunday":
return "Weekend";
default:
return "Unknown";
}
}
console.log(getDayType("Friday")); // Weekday
console.log(getDayType("Saturday")); // Weekend
Multiple cases share the same return statement. No break is needed here because return exits the function entirely.
Using default
The default block is like the else in an if-else chain. It runs when none of the cases match. It is not required, but it is good practice to include it for handling unexpected values.
The switch(true) Pattern
There is a lesser-known way to use switch that surprises a lot of developers. Instead of passing a variable, you pass true as the expression and use boolean conditions in each case. This lets you do range checks inside a switch, which normally is not possible.
const score = 73;
switch (true) {
case score >= 90:
console.log("Grade: A");
break;
case score >= 80:
console.log("Grade: B");
break;
case score >= 70:
console.log("Grade: C");
break;
case score >= 60:
console.log("Grade: D");
break;
default:
console.log("Grade: F");
}
// Output: Grade: C
JavaScript evaluates each case as a boolean expression. The first one that evaluates to true is the one that matches. This pattern is not extremely common, but you will see it in codebases occasionally and it is worth recognizing. It is still generally better to use if-else for range checks since that is more intuitive, but switch(true) is a legitimate tool when you want the visual structure of a switch with the flexibility of boolean conditions.
When to Use switch vs if-else
This is a question that comes up often, and the honest answer is: it depends on what you are comparing.
Use switch when:
You are checking one variable or expression against many fixed, exact values
All the cases are of the same type (all strings, all numbers)
You want the code to be easier to scan at a glance
Use if-else when:
Your conditions involve ranges (greater than, less than)
You are combining multiple variables in a single condition
The conditions are complex boolean expressions
Here is a side-by-side comparison to make this concrete.
This is a situation where switch is the right choice:
// Mapping HTTP status codes to messages
function getStatusMessage(code) {
switch (code) {
case 200:
return "OK";
case 201:
return "Created";
case 400:
return "Bad Request";
case 401:
return "Unauthorized";
case 404:
return "Not Found";
case 500:
return "Internal Server Error";
default:
return "Unknown Status";
}
}
console.log(getStatusMessage(404)); // Not Found
And this is where if-else is the better tool:
// Categorizing response times
function getPerformanceLabel(responseTimeMs) {
if (responseTimeMs < 100) {
return "Excellent";
} else if (responseTimeMs < 300) {
return "Good";
} else if (responseTimeMs < 1000) {
return "Acceptable";
} else {
return "Poor";
}
}
console.log(getPerformanceLabel(250)); // Good
console.log(getPerformanceLabel(1500)); // Poor
You cannot express "less than 300" as a switch case in the normal sense. That is a range check, and if-else handles it naturally.
The Ternary Operator
The ternary operator is a compact way to write a simple if-else. Instead of three to five lines, you write one. The syntax is:
condition ? valueIfTrue : valueIfFalse
Here is the same logic written both ways:
// With if-else
let label;
if (score >= 50) {
label = "Pass";
} else {
label = "Fail";
}
// With ternary
const label = score >= 50 ? "Pass" : "Fail";
Both produce the same result. The ternary version is cleaner when the logic is simple and fits on one line. It is especially common for assigning values inline or for conditional rendering in JavaScript frameworks.
function getGreeting(hour) {
return hour < 12 ? "Good morning" : "Good afternoon";
}
console.log(getGreeting(9)); // Good morning
console.log(getGreeting(14)); // Good afternoon
Where ternary starts causing problems is when developers nest them:
// This is hard to read and should be avoided
const grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : "F";
This works but it is genuinely difficult to parse at a glance. If you find yourself nesting ternaries, that is a strong signal to switch back to if-else. The ternary operator is a tool for simple two-branch decisions, not for complex multi-branch logic.
A good rule of thumb: if the ternary fits comfortably on one line and any developer reading your code would understand it immediately, use it. If you have to pause and think through what it says, write if-else instead.
Short-Circuit Evaluation with && and ||
JavaScript's && (AND) and || (OR) operators do something that surprises a lot of developers: they do not just return true or false. They return one of the actual values involved in the expression. This behavior is called short-circuit evaluation, and once you understand it, you will see it everywhere.
How && Works
With &&, JavaScript evaluates the left side first. If it is falsy, it stops and returns that value without looking at the right side. If the left side is truthy, it evaluates and returns the right side.
const user = { name: "Alex", isAdmin: true };
// Only runs the right side if user exists and is truthy
user && console.log("User found:", user.name); // User found: Alex
const nullUser = null;
nullUser && console.log("This never runs"); // Nothing happens, null is returned
This pattern is extremely common in React for conditional rendering:
// Only renders the paragraph if user is truthy
function UserPanel({ user }) {
return (
<div>
{user && <p>Welcome, {user.name}</p>}
</div>
);
}
If user is null or undefined, the && short-circuits and nothing renders. If user exists, the paragraph renders. No if statement needed.
How || Works
With ||, JavaScript evaluates the left side first. If it is truthy, it stops and returns that value. If the left side is falsy, it evaluates and returns the right side. This makes || useful for providing default values.
function greet(name) {
const displayName = name || "Guest";
console.log("Hello, " + displayName);
}
greet("Maria"); // Hello, Maria
greet(""); // Hello, Guest
greet(null); // Hello, Guest
When name is falsy (empty string, null, undefined), displayName falls back to "Guest". This is one of the most common patterns in JavaScript.
The Nullish Coalescing Operator ??
One thing to be aware of: || uses falsy checks, which means it treats 0 and "" as values to skip over. If you want a fallback only for null and undefined but not for 0 or empty strings, use the nullish coalescing operator ?? instead.
const count = 0;
console.log(count || 10); // 10 — 0 is falsy, so || skips it
console.log(count ?? 10); // 0 — 0 is not null/undefined, so ?? keeps it
This distinction matters when working with numbers or other values where 0 or an empty string is a valid, meaningful result. When in doubt about which to use, ?? is the safer default for fallback values.
Guard Clauses: The Antidote to Nested if Statements
Earlier we looked at what deeply nested if statements look like and why they are a problem. The solution is the guard clause pattern. The idea is simple: instead of wrapping your main logic inside conditions, you exit early when conditions are not met.
Here is the nested version first:
function processPayment(user, amount, paymentMethod) {
if (user) {
if (user.isActive) {
if (amount > 0) {
if (paymentMethod === "card" || paymentMethod === "paypal") {
return `Processing $${amount} via ${paymentMethod}`;
} else {
return "Unsupported payment method";
}
} else {
return "Amount must be greater than zero";
}
} else {
return "User account is inactive";
}
} else {
return "No user provided";
}
}
Four levels of nesting for logic that is not even that complex. Now here is the same function rewritten with guard clauses:
function processPayment(user, amount, paymentMethod) {
if (!user) return "No user provided";
if (!user.isActive) return "User account is inactive";
if (amount <= 0) return "Amount must be greater than zero";
if (paymentMethod !== "card" && paymentMethod !== "paypal") {
return "Unsupported payment method";
}
// actual logic, clean and unobstructed
return `Processing $${amount} via ${paymentMethod}`;
}
The behavior is identical. But the second version is dramatically easier to read. Each guard clause handles one failure condition and exits immediately. By the time you reach the actual logic at the bottom, you know every guard passed. The happy path is clear and unindented.
This pattern is one of those things that separates code that feels good to work with from code that feels like a maze. Get comfortable with it.
Putting It All Together
Here is a larger example that combines the major structures covered in this post. It simulates an order processing function:
function processOrder(order) {
// Guard clauses first — exit early on invalid input
if (!order) return "No order provided";
if (order.total <= 0) return "Invalid order total";
const { total, membershipTier, paymentMethod, couponCode } = order;
// Apply discount based on membership (if-else for tiered logic)
let discountRate = 0;
if (membershipTier === "gold") {
discountRate = 0.20;
} else if (membershipTier === "silver") {
discountRate = 0.10;
} else if (membershipTier === "bronze") {
discountRate = 0.05;
}
// Apply coupon if present (ternary for simple two-outcome assignment)
const extraDiscount = couponCode === "SAVE10" ? 0.10 : 0;
const discountedTotal = total - total * (discountRate + extraDiscount);
// Set processing fee based on payment method (switch for exact string matches)
let processingFee = 0;
switch (paymentMethod) {
case "credit_card":
processingFee = 1.5;
break;
case "paypal":
processingFee = 2.0;
break;
case "bank_transfer":
processingFee = 0;
break;
default:
return "Unsupported payment method";
}
const finalTotal = discountedTotal + processingFee;
// Fallback label for membership tier using ??
const memberLabel = membershipTier ?? "free";
return `Order total: $${finalTotal.toFixed(2)} (${memberLabel} tier)`;
}
console.log(processOrder({
total: 500,
membershipTier: "silver",
paymentMethod: "credit_card",
couponCode: "SAVE10"
}));
// Order total: $426.50 (silver tier)
console.log(processOrder({
total: 300,
membershipTier: null,
paymentMethod: "paypal",
couponCode: null
}));
// Order total: $302.00 (free tier)
Notice how each tool is doing the job it is best suited for. Guard clauses handle validation upfront. if-else handles tier-based discounts. Ternary handles the coupon check because it is a simple two-outcome assignment. switch handles payment methods because those are exact string matches. ?? provides the membership label fallback without incorrectly skipping valid falsy values.
Quick Reference
| Structure | Best for |
|---|---|
if |
Single condition, one outcome |
if-else |
Single condition, two outcomes |
else if |
Multiple conditions, ranges, complex logic |
switch |
One value checked against many exact matches |
Ternary ? : |
Simple two-outcome assignment on a single line |
&& |
Run something only if the left side is truthy |
| ` | |
?? |
Provide a fallback only for null or undefined |
| Guard clauses | Flatten nested conditions using early returns |
Control flow is one of those things that feels obvious once you understand it, but the difference between code that is easy to maintain and code that turns into a maze often comes down to whether you knew which tool to reach for. switch for exact matches, if-else for ranges and complex conditions, ternary for simple inline assignments, short-circuit for guards and defaults, and early returns to keep your functions flat and readable.
These are not isolated concepts. They work together. Get comfortable with each one individually, then start noticing how they interact in real code. Once you can look at a function and immediately recognize which pattern is being used and why, you are thinking like a developer who actually understands control flow rather than just using it by muscle memory.
The next natural step from here is loops, where you combine repetition with the same conditional logic you just learned. After that, error handling, where try/catch adds another layer of flow control for things that can go wrong at runtime.