JavaScript Conditional Statements: if, else, ternary, switch — and the Modern Alternatives

Link copied
JavaScript Conditional Statements: if, else, ternary, switch — and the Modern Alternatives

JavaScript Conditional Statements: if, else, ternary, switch — and the Modern Alternatives

JS Tutorial Module 1: Foundations Lesson 1.5

Branching — choosing one path or another based on a value — is the second most fundamental thing a program does (after computing values themselves). JavaScript gives you four primary tools for it: if/else, the ternary operator ?:, the switch statement, and short-circuit expressions using && / || / ??.

This lesson covers all four, plus the patterns that experienced developers reach for instead of conditionals — lookup objects and early returns — to keep branching code readable.

if / else if / else #

The most general form. Takes any expression, runs the matching block.

if (age < 13) {
  category = 'child';
} else if (age < 20) {
  category = 'teen';
} else if (age < 65) {
  category = 'adult';
} else {
  category = 'senior';
}

The expression in if (...) is evaluated for truthiness, not strict booleans. The six falsy values we covered in Lesson 1.3 (false, 0, '', null, undefined, NaN) take the else branch. Everything else takes the if branch.

This means if (user) is shorthand for if (user is anything but null/undefined/false/0/''/NaN). Convenient and idiomatic — but be careful about strings that contain '0' (truthy) and arrays/objects that are empty (also truthy).

Always use braces #

JavaScript lets you skip braces on single-statement bodies:

if (loggedIn) goToDashboard();

Don't. It's the cause of the most famous bug in iOS history (the "goto fail" SSL bug) and many smaller ones. Always brace:

if (loggedIn) {
  goToDashboard();
}

Most linters (Prettier, ESLint with curly: 'all') enforce this automatically.

The ternary operator: ?: #

The one-and-only conditional expression — it returns a value, unlike if which is a statement.

const label = isAdmin ? 'Admin' : 'User';
const greeting = `Hello, ${name || 'stranger'}!`;
return errors.length > 0 ? renderErrors(errors) : renderForm();

The shape is: condition ? value-if-truthy : value-if-falsy.

Ternaries shine when you're producing a single value with a single decision. They become hard to read when nested:

// Edge of readable
const rank = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : 'F';

// Don't do this
const x = a ? (b ? (c ? 1 : 2) : 3) : (d ? 4 : 5);

Rule of thumb: one ternary inline is great. Two is fine. Three is the upper limit before you should reach for if/else or a lookup object.

switch #

For matching a value against multiple discrete cases.

switch (status) {
  case 'pending':
    showSpinner();
    break;
  case 'success':
    showResult();
    break;
  case 'error':
  case 'timeout':       // fall-through — both run the same block
    showError();
    break;
  default:
    showFallback();
}

Key rules:

  • switch uses strict equality (===) to match cases. No type coercion.
  • break is required after each case unless you want fall-through. Forgetting break is the classic switch bug.
  • default runs when no case matches; conventionally placed last.
  • Cases can share blocks by stacking (the 'error' / 'timeout' example above).
  • You can declare block-scoped let/const inside a case if you wrap it in { }:
switch (kind) {
  case 'add': {
    const next = current + 1;
    return next;
  }
  case 'sub': {
    const next = current - 1;
    return next;
  }
}

Without the braces, the let next declarations would collide because all cases share the same switch scope.

switch (true) for ranges #

A niche but useful pattern: matching against expressions rather than discrete values:

switch (true) {
  case score >= 90: return 'A';
  case score >= 80: return 'B';
  case score >= 70: return 'C';
  default:          return 'F';
}

Reads cleanly for tiered ranges. Use sparingly — many teams find it confusing.

Short-circuit conditionals #

We covered the logical operators in Lesson 1.4. Their short-circuit behavior doubles as a conditional construct:

user && sendWelcome(user);          // call only if user is truthy
const name = profile?.name ?? 'You'; // default chain
renderHeader();
showFooter && showFooter();         // call if showFooter exists

These are statement-equivalent to single-branch ifs, but expression-shaped — they fit inline where statements wouldn't.

The linter rule no-unused-expressions may complain about &&-as-statement; many teams turn it off for this idiom.

The fifth tool: lookup objects #

For any conditional that maps one value to another value, an object lookup is often clearer than if or switch:

// Conditional version
function statusLabel(status) {
  if (status === 'pending') return 'Working on it';
  if (status === 'success') return 'All done';
  if (status === 'error')   return 'Something broke';
  return 'Unknown';
}

// Lookup version
const STATUS_LABELS = {
  pending: 'Working on it',
  success: 'All done',
  error:   'Something broke',
};
function statusLabel(status) {
  return STATUS_LABELS[status] ?? 'Unknown';
}

The lookup version:

  • Is shorter
  • Separates data (the mapping) from logic (the lookup)
  • Is trivially extensible (add an entry, not a branch)
  • Performs slightly better in V8 (constant-time object property access)

When each branch only produces a value, prefer the lookup. When each branch does work (calls a function, runs side effects), keep the switch or if chain.

Early returns: the anti-nesting pattern #

The other strong pattern: instead of nesting conditions, return early on the exceptional cases.

// Nested — hard to read past three levels
function processOrder(order) {
  if (order) {
    if (order.items.length > 0) {
      if (order.paid) {
        ship(order);
      } else {
        chargeAndShip(order);
      }
    } else {
      throw new Error('No items');
    }
  }
}

// Early-return — happy path at the bottom
function processOrder(order) {
  if (!order) return;
  if (order.items.length === 0) throw new Error('No items');
  if (!order.paid) {
    chargeAndShip(order);
    return;
  }
  ship(order);
}

The early-return version keeps every guard at the same indentation level. The reader scans down a series of if-return statements, each ruling out one edge case, until they reach the main logic.

This is one of the most universally agreed-on style improvements in modern JS. Worth internalizing as a habit.

Putting it together: a decision tree #

Which conditional construct to use for a given problem:

Situation Best tool
Single decision producing a value Ternary ?:
Single decision doing work Short if (with braces)
Multiple branches doing different work if/else if/else chain
One value mapping to another value Lookup object
Many discrete cases, each doing work switch
Tiered range comparisons switch (true) or chained ternary
Guard against bad inputs Early return
Optional side-effect Short-circuit &&

These aren't rules — they're starting points. The right choice depends on what reads well in context.

A worked refactor #

Here's a real-shaped function and three versions of it, each progressively cleaner.

// Version 1 — nested ifs
function describeUser(user) {
  let description;
  if (user) {
    if (user.active) {
      if (user.role === 'admin') {
        description = 'Active admin: ' + user.name;
      } else {
        description = 'Active user: ' + user.name;
      }
    } else {
      description = 'Inactive: ' + user.name;
    }
  } else {
    description = 'No user';
  }
  return description;
}

// Version 2 — early returns
function describeUser(user) {
  if (!user) return 'No user';
  if (!user.active) return `Inactive: ${user.name}`;
  if (user.role === 'admin') return `Active admin: ${user.name}`;
  return `Active user: ${user.name}`;
}

// Version 3 — lookup table for the role-based labels
const ROLE_LABEL = { admin: 'Active admin', user: 'Active user' };
function describeUser(user) {
  if (!user) return 'No user';
  if (!user.active) return `Inactive: ${user.name}`;
  const label = ROLE_LABEL[user.role] ?? 'Active user';
  return `${label}: ${user.name}`;
}

Each version is correct. Version 3 is the one most teams settle on because it's open for extension — adding a new role means adding a row to ROLE_LABEL, not a new branch.

What's next #

Lesson 1.6 covers loopsfor, while, do-while, for-of, for-in — and explains when each one wins, plus why most modern code uses array methods (map, filter, reduce) instead of explicit loops for transforming data.

Try it yourself #

The switch fall-through bug is the easiest one to feel. Predict the output:

YouPredict the output:
const x = 'a';
switch (x) {
case 'a': console.log('A');
case 'b': console.log('B');
default: console.log('D');
}
Claude · used js_sandboxOutput: A, then B, then D — three log lines, not one.

Without break, switch falls through to every subsequent case. case 'a' matches, then execution continues into case 'b' and the default. Add break after each case to stop the fall-through.

One missing keyword, three log lines instead of one. The forgotten break is the most common switch bug in any language that has C-style switches.

Up next in JavaScript

More from this topic

View all JavaScript articles →

Enjoyed this article?

Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email address will not be published. Required fields are marked *