Episode 1 — Fundamentals / 1.20 — Functions

1.20.c — Return Values and Scope

In one sentence: The return statement sends a value back to the caller and exits the function, while JavaScript's scope rules — global, function, block, and the lexical scope chain — determine which variables a function can see, culminating in closures: functions that remember the environment where they were created.

Navigation: ← 1.20.b — Parameters and Arguments · 1.20.d — Arrow Functions →


1. The return statement

return does two things:

  1. Sends a value back to the caller.
  2. Immediately exits the function — no code after return runs.
function double(n) {
  return n * 2;
  console.log("This never runs"); // unreachable code
}

const result = double(5);
console.log(result); // 10

Implicit undefined

If a function has no return statement, or return with no value, the function returns undefined:

function greet(name) {
  console.log(`Hello, ${name}`);
  // no return statement
}

const result = greet("Alice"); // logs "Hello, Alice"
console.log(result);           // undefined

function earlyExit() {
  return; // returns undefined explicitly
}
console.log(earlyExit()); // undefined

2. Returning multiple values

JavaScript functions can only return one value. To return multiple pieces of data, use an object or array and destructure at the call site:

Via object (named fields — clearest)

function getMinMax(arr) {
  return {
    min: Math.min(...arr),
    max: Math.max(...arr),
  };
}

const { min, max } = getMinMax([3, 7, 1, 9]);
console.log(min, max); // 1 9

Via array (positional — concise)

function divide(a, b) {
  const quotient = Math.floor(a / b);
  const remainder = a % b;
  return [quotient, remainder];
}

const [q, r] = divide(17, 5);
console.log(q, r); // 3 2

When to choose which:

  • Object — when the caller benefits from named keys (self-documenting).
  • Array — when order is obvious or you want shorter syntax (like coordinate pairs [x, y]).

3. Early return pattern (guard clauses)

Instead of deeply nested if/else, use early returns to handle edge cases first:

Without early return (deep nesting)

function processOrder(order) {
  if (order) {
    if (order.items.length > 0) {
      if (order.paymentMethod) {
        // actual logic buried deep
        return chargeAndShip(order);
      } else {
        return "No payment method";
      }
    } else {
      return "No items in order";
    }
  } else {
    return "No order provided";
  }
}

With early return (flat and readable)

function processOrder(order) {
  if (!order) return "No order provided";
  if (order.items.length === 0) return "No items in order";
  if (!order.paymentMethod) return "No payment method";

  // Happy path — the main logic
  return chargeAndShip(order);
}

Benefits:

  • Reduces indentation and cognitive load.
  • Edge cases are visible at the top.
  • The "happy path" is the last block — easy to find.

4. Scope — where variables live

Scope determines where a variable is accessible. JavaScript has three main scope levels:

Global scope

Variables declared outside any function or block are global — accessible everywhere:

const appName = "MyApp"; // global

function showApp() {
  console.log(appName); // accessible
}
showApp(); // "MyApp"

Caution: Global variables can be overwritten by any code — minimize their use.

Function scope

Variables declared with var, let, or const inside a function are local to that function:

function calculate() {
  var x = 10;
  let y = 20;
  const z = 30;
  console.log(x + y + z); // 60
}

calculate();
// console.log(x); // ReferenceError — x is not defined

Block scope (let and const only)

let and const are scoped to the nearest enclosing block ({ }):

if (true) {
  let blockVar = "I'm block-scoped";
  const alsoBlock = "Me too";
  var notBlock = "I escape the block!";
}

// console.log(blockVar);  // ReferenceError
// console.log(alsoBlock); // ReferenceError
console.log(notBlock);     // "I escape the block!" — var ignores block scope

Scope comparison table

KeywordFunction-scoped?Block-scoped?Hoisted?TDZ?
varYesNoYes (as undefined)No
letYesYesYes (uninitialized)Yes
constYesYesYes (uninitialized)Yes

TDZ = Temporal Dead Zone — the period between entering the scope and the declaration line. Accessing a let/const variable in the TDZ throws a ReferenceError.


5. Lexical scoping (static scoping)

JavaScript uses lexical scoping — a function's scope is determined by where it is written in the source code, not where it is called.

const outer = "I'm outer";

function parent() {
  const inner = "I'm inner";

  function child() {
    console.log(outer); // "I'm outer" — visible
    console.log(inner); // "I'm inner" — visible
  }

  child();
}

parent();

The child function can access inner because child is written inside parent. Even if you somehow called child from a different location, it would still look up the scope where it was defined, not where it was invoked.


6. Scope chain

When JavaScript encounters a variable, it looks up the scope chain:

  1. Current function scope.
  2. Enclosing function scope (parent).
  3. Next enclosing scope, and so on...
  4. Global scope.
  5. If not found anywhere: ReferenceError.
const a = "global";

function outer() {
  const b = "outer";

  function middle() {
    const c = "middle";

    function inner() {
      const d = "inner";
      console.log(a); // "global"  — found at global level
      console.log(b); // "outer"   — found at outer level
      console.log(c); // "middle"  — found at middle level
      console.log(d); // "inner"   — found at current level
    }

    inner();
  }

  middle();
}

outer();
Scope chain for inner():
  inner scope  →  middle scope  →  outer scope  →  global scope
      d              c                b                a

7. Variable shadowing

When a variable in an inner scope has the same name as one in an outer scope, the inner variable shadows (hides) the outer one:

const color = "red"; // global

function paint() {
  const color = "blue"; // shadows the global 'color'
  console.log(color);   // "blue"

  function detail() {
    const color = "green"; // shadows paint's 'color'
    console.log(color);    // "green"
  }

  detail();
}

paint();
console.log(color); // "red" — the global is untouched

Shadowing is legal but can be confusing. Linters like ESLint have a no-shadow rule to flag this.


8. Closures — functions that remember

A closure is created when a function retains access to variables from its lexical scope, even after the outer function has finished executing.

The fundamental example

function createGreeter(greeting) {
  // 'greeting' is in the outer scope
  return function (name) {
    // This inner function "closes over" greeting
    return `${greeting}, ${name}!`;
  };
}

const sayHello = createGreeter("Hello");
const sayHola = createGreeter("Hola");

// createGreeter has already returned, but the inner function remembers 'greeting'
console.log(sayHello("Alice")); // "Hello, Alice!"
console.log(sayHola("Bob"));    // "Hola, Bob!"

Why closures work

When createGreeter("Hello") runs:

  1. A new scope is created with greeting = "Hello".
  2. The returned inner function keeps a reference to that scope.
  3. Even after createGreeter finishes, the scope is not garbage-collected because the inner function still references it.

9. Closure practical examples

Counter with private state

function createCounter(start = 0) {
  let count = start;

  return {
    increment() { return ++count; },
    decrement() { return --count; },
    getCount()  { return count; },
    reset()     { count = start; return count; },
  };
}

const counter = createCounter(10);
console.log(counter.increment()); // 11
console.log(counter.increment()); // 12
console.log(counter.decrement()); // 11
console.log(counter.getCount());  // 11
counter.reset();
console.log(counter.getCount());  // 10

// 'count' is NOT accessible from outside
// console.log(counter.count); // undefined — truly private

Private variables / data encapsulation

function createBankAccount(initialBalance) {
  let balance = initialBalance; // private
  const transactions = [];      // private

  return {
    deposit(amount) {
      if (amount <= 0) throw new Error("Deposit must be positive");
      balance += amount;
      transactions.push({ type: "deposit", amount, date: new Date() });
      return balance;
    },
    withdraw(amount) {
      if (amount <= 0) throw new Error("Withdrawal must be positive");
      if (amount > balance) throw new Error("Insufficient funds");
      balance -= amount;
      transactions.push({ type: "withdrawal", amount, date: new Date() });
      return balance;
    },
    getBalance() {
      return balance;
    },
    getTransactions() {
      return [...transactions]; // Return copy, not the original
    },
  };
}

const account = createBankAccount(100);
account.deposit(50);           // 150
account.withdraw(30);          // 120
console.log(account.getBalance()); // 120
// account.balance — undefined (private)

Function factory

function createMultiplier(factor) {
  return function (num) {
    return num * factor;
  };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);
const percent = createMultiplier(0.01);

console.log(double(50));   // 100
console.log(triple(50));   // 150
console.log(percent(250)); // 2.5

Memoization (caching results)

function memoize(fn) {
  const cache = {}; // closed over — persists across calls

  return function (...args) {
    const key = JSON.stringify(args);
    if (key in cache) {
      console.log("Cache hit:", key);
      return cache[key];
    }
    console.log("Computing:", key);
    const result = fn(...args);
    cache[key] = result;
    return result;
  };
}

const slowSquare = (n) => {
  // Simulate expensive operation
  return n * n;
};

const memoSquare = memoize(slowSquare);
memoSquare(5); // "Computing: [5]" → 25
memoSquare(5); // "Cache hit: [5]" → 25
memoSquare(3); // "Computing: [3]" → 9

10. Common closure pitfall — loop variable capture

The classic bug

for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i); // 3, 3, 3 — NOT 0, 1, 2
  }, 100);
}

Why? var is function-scoped. All three callbacks share the same i. By the time they run, the loop has finished and i is 3.

Fix 1: Use let (block-scoped)

for (let i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i); // 0, 1, 2 — each iteration gets its own 'i'
  }, 100);
}

Fix 2: IIFE (pre-ES6 pattern)

for (var i = 0; i < 3; i++) {
  (function (captured) {
    setTimeout(function () {
      console.log(captured); // 0, 1, 2
    }, 100);
  })(i);
}

Fix 3: Closure via factory function

function makeLogger(value) {
  return function () {
    console.log(value);
  };
}

for (var i = 0; i < 3; i++) {
  setTimeout(makeLogger(i), 100); // 0, 1, 2
}

Modern best practice: Always use let in for loops. The var loop bug is one of the most common JavaScript interview questions.


Key takeaways

  1. return sends a value back and exits the function; no return means the function returns undefined.
  2. Return multiple values via an object (named) or array (positional), then destructure at the call site.
  3. Early return guard clauses keep code flat and readable — handle edge cases first.
  4. Scope comes in three levels: global, function, and block (let/const only).
  5. Lexical scoping means a function's variable access is determined by where it is written, not where it is called.
  6. The scope chain walks outward from the current scope to global; variables in closer scopes shadow outer ones.
  7. A closure is a function that retains access to its lexical scope, even after the outer function returns — enabling private state, factories, and memoization.
  8. The classic var-in-a-loop bug is caused by closures sharing the same function-scoped variable — fix it with let.

Explain-It Challenge

Explain without notes:

  1. What does a function return if it has no return statement?
  2. Describe the scope chain — how does JavaScript resolve a variable name?
  3. What is a closure and why is it useful?
  4. Why does var i in a for loop cause setTimeout callbacks to all print the same value?

Navigation: ← 1.20.b — Parameters and Arguments · 1.20.d — Arrow Functions →