JavaScript if/else Statements: A Beginner’s Guide

Thesis (short answer): Use truthiness checks for “is anything here?”, use strict comparisons (===, !==, <, >) for exact matches or numeric ranges, prefer guard clauses (early returns) to keep branches simple, and only refactor to switch or a lookup object when you’re matching the same normalized key against many fixed outcomes. The example below shows these choices in a copy‑able validation function and explains why each decision helps avoid common failures.

Alex Carter is the editorial name responsible for Vandutz Academy’s technical direction; the site does not claim a specific degree, employer, certification, or public profile.

Quick reproducible validation example (copy, run, inspect)

This function is intentionally explicit: it normalizes inputs once, uses guard clauses, and mixes truthiness and strict checks where appropriate. Run it in a browser console or Node to reproduce the behaviors described below.

function validateSignup(form = {}) {
  // Normalize once
  const username = String(form.username ?? "").trim(); // always a string
  const ageNum = Number(form.age);                     // convert inputs to a number
  const ref = String(form.referrer ?? "").trim();      // optional code

  // Guard clauses (early returns) make intent clear and avoid nested blocks
  if (!username) {
    console.warn("validateSignup: missing username", { username });
    return { ok: false, reason: "Username is required." };
  }

  if (Number.isNaN(ageNum)) {
    console.warn("validateSignup: age not a number", { age: form.age });
    return { ok: false, reason: "Age must be a number." };
  }

  if (ageNum < 13) {
    return { ok: false, reason: "You must be at least 13." };
  }

  // Optional field: use truthiness to mean "provided and non-empty"
  // but then use an explicit length check because empty string is falsy
  if (ref && ref.length !== 6) {
    return { ok: false, reason: "Referrer code must be exactly 6 characters." };
  }

  return { ok: true };
}

// Reproducible calls:
console.log(validateSignup({ username: "  Ada  ", age: "14" }));           // ok
console.log(validateSignup({ username: "", age: "16" }));                  // missing username
console.log(validateSignup({ username: "Ada", age: "x" }));                // age NaN
console.log(validateSignup({ username: "Ada", age: 12 }));                 // too young
console.log(validateSignup({ username: "Ada", age: 16, referrer: "abc" })); // ref wrong length

Why these choices? Truthiness vs explicit comparisons

Truthiness answers “is there anything here?”—it’s cheap and readable for presence checks, especially on strings after trim(). But truthiness conflates several values (0, "", null, undefined, NaN) as falsy. Use explicit checks when 0 or empty collections are valid inputs, or when you need precise behavior.

  • Truthiness good: "did the user type a username?" → if (username).
  • Strict comparisons good: numeric ranges, exact lengths, or equality checks → if (qty === 0), if (ref.length !== 6).
  • Read MDN for the authoritative list of falsy values: Truthy.

Truth tables for JavaScript conditionals

When you reason about combinations of booleans or boolean-like values, a compact truth table removes ambiguity. Two important notes for JavaScript: logical operators short‑circuit and they return the actual operand (not always a boolean). For conditional testing you care about truthiness of the result; for expressions you care about the returned value.

// Canonical boolean truth table (truthiness used in conditionals)
A     B     A && B     A || B     !A
true  true   true      true      false
true  false  false     true      false
false true   false     true      true
false false  false     false     true

// JS nuance: operators return operands, not just true/false:
null && "x"   // null  (falsy)
"foo" && "x"  // "x"   (truthy)
0 || "default"// "default"

Use a truth table when you maintain complex combinations (a && (b || c)) or when multiple flags interact. It forces you to enumerate edge cases instead of assuming operator precedence will do the "right" thing.

Guard clauses: keep conditions local and readable

Return early from a function on failure. Advantages:

  • Each condition states one intention. Tests are easier to read and unit-test.
  • Reduces nesting, so success path stays near the top of the function.
  • Failure modes are explicit: you can log the reason before returning.
// Bad: nested and hard to follow
function old(f) {
  if (f) {
    if (f.name) {
      if (!Number.isNaN(Number(f.age))) {
        // ... continue
      }
    }
  }
}

// Better: guard clauses
function better(f) {
  if (!f) return notify("missing form");
  if (!f.name) return notify("missing name");
  const age = Number(f.age);
  if (Number.isNaN(age)) return notify("age not a number");
  // success path continues here
}

Advanced guard patterns: group related guards into semantic checks (e.g., const missing = missingUserFields(form)), return structured error objects ({ ok:false, code: "MISSING_USERNAME" }) for programmatic handling, and in async code reject or throw early instead of nesting callbacks. Avoid guards that mutate shared state as side effects; guards should be pure checks whenever possible so unit tests can target them directly.

Nested-condition debugging: strategies that scale

Deeply nested if/else blocks are the most time-consuming to debug. Triage with the following approach:

  • Binary isolation: comment or temporarily return early at midpoints to narrow which level behaves unexpectedly.
  • Instrument finely: insert concise logs that include both value and type (console.log("x:", x, "type:", typeof x)) and include a short path label so you can see which branch executed.
  • Extract predicates: move complex conditions into named functions (const isEligible = (u,a) => u && a >= 18). Small functions are easier to unit-test and reason about.
  • Use a truth table or small matrix (below) to exhaustively run combinations in a unit test or REPL session.
  • Look for mutation across branches: shared variables changed in one branch can affect later checks. Prefer immutable names for normalized inputs.

These tactics reduce assumption-driven debugging: instead of "I think this is true", you get repeatable, labeled observations you can assert in tests.

Small test matrix (for validateSignup)

Hand‑written matrices make it easy to see coverage for common and edge cases. Below is a compact matrix you can convert to unit tests.

CaseusernameagereferrerExpectedNotes
Valid"Ada""14"undefinedokNormalization and numeric parse succeed
Missing username"""16"-failtruthiness check should reject
Age non-number"Ada""x"-failNumber.isNaN guard
Too young"Ada"12-failnumeric range check
Bad ref"Ada"16"abc"failtruthy ref + length check

Switch vs lookup object: decision rules and examples

Choice depends on the shape of the problem.

  • Use switch when you compare a single (normalized) value to a moderate number of distinct branches and branches are short.
  • Use a lookup object (or Map) when you map keys to data or small functions — it treats cases as data and reduces the risk of fallthrough and forgotten defaults.

Switch example (watch for fallthrough)

function dayLabel(input) {
  const day = String(input ?? "").toLowerCase();
  switch (day) {
    case "mon":
    case "monday":
      return "Start of week"; // stacked cases intentional
    case "wed":
    case "wednesday":
      return "Midweek";
    case "fri":
    case "friday":
      return "Almost weekend";
    default:
      return "Just another day";
  }
}

Failure mode: a missing break or return in switch can silently fall through. Keep cases short, or prefer a lookup for many entries.

Lookup object example (preferred for mapping)

const messages = {
  mon: "Start of week",
  monday: "Start of week",
  wednesday: "Midweek",
  friday: "Almost weekend"
};

function lookupDay(input) {
  const key = String(input ?? "").toLowerCase();
  // Normalize and provide a safe default
  return messages[key] ?? "Just another day";
}

Advantages: clear data shape, easy to add or test entries, fewer control-flow bugs. If you need functions as values, use functions as the mapped values (or a Map if keys are non-string).

Targeted symptom → decision checklist (fast fixes)

  • Symptom: "My if block never runs." Likely cause: value is falsy ("" / 0 / null / undefined / NaN). Decision: log the value and its type, then decide whether to use truthiness or an exact comparison (e.g., === 0).
  • Symptom: "Else always runs." Likely cause: used loose equality or wrong variable. Decision: switch to ===, verify variable names, add console logs for both operands.
  • Symptom: "Condition works once then acts weird." Likely cause: assignment in condition (=). Decision: change to comparison (===); enable a linter rule to catch this.
  • Symptom: "if (x === 5 || 7) is always true." Cause: second operand is a literal truthy value. Decision: repeat the comparison: if (x === 5 || x === 7).
  • Symptom: "Empty array passes presence check." Cause: objects/arrays are always truthy. Decision: check size (arr.length > 0).
  • Symptom: "Switch falls through." Cause: missing break or return. Decision: add explicit break or convert to a lookup.
  • Symptom: "Strings from inputs compare strangely." Cause: values are strings, not numbers. Decision: convert with Number() once and guard with Number.isNaN().

Practical debugging steps that pay off

  1. Log value and type immediately before the conditional: console.log("age =", age, "typeof =", typeof age).
  2. Normalize once at the top of a function (trim strings, convert numbers) and reuse the normalized variables.
  3. Parenthesize mixed && and || expressions so intent is explicit.
  4. Break long conditions into named booleans: const isAdult = age >= 18; makes tests self-documenting.
  5. Test boundary values (0, 1, -1, empty string) and unusual types (null, undefined, NaN).
  6. If a branch shouldn't run, log why it didn’t: if (!username) { console.warn("Missing username"); return; }.

Limitations and failure modes to keep in mind

  • if/else chains that grow beyond ~5 branches often hide missing cases — they become hard to read and to unit-test. Consider a lookup or splitting logic into smaller functions.
  • Switch statements without a default can silently ignore unexpected values introduced later.
  • Truthiness checks conflate several falsy values; if 0 or "" are legitimate, prefer explicit comparisons.
  • Mapping keys are sensitive to normalization: forget to toLowerCase() or trim() and the lookup will miss values.
  • Some bugs only appear with real user data (extra whitespace, non‑ASCII characters, numeric strings). Add small tests that mirror real inputs.

Where to go next (resources)

Leave a Comment

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

Scroll to Top