Skip to main content

Command Palette

Search for a command to run...

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.

Updated
19 min read
Control Flow in JavaScript: Making Your Code Think

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:

  • if

  • if-else

  • else if chains

  • switch

  • Ternary 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:

  • false

  • 0

  • "" (empty string)

  • null

  • undefined

  • NaN

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.

More from this blog