Week 3: Closures & Lexical Scope

This is the week most people coming from another language actually stop and think about JavaScript differently. A closure isn't a special syntax — it's just what happens every time a function is created inside another function. Understanding why that happens, through lexical scoping, is what makes private state, factories and half the patterns in a typical codebase make sense instead of feeling like magic.

Phase 2 of 8 Week 3 of 14 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain what a closure captures, and why it keeps working after the outer function has returned
  • Trace the scope chain to explain why an inner function can see an outer variable
  • Spot and fix the classic "wrong value in a loop" closure bug

1. What a Closure Actually Is

A closure is a function bundled together with references to the variables from the scope it was created in. In JavaScript, this isn't an opt-in feature — every function forms a closure over its surrounding scope automatically, whether you use that fact or not:

a closure in action
function createCounter() {
  let count = 0; // lives inside createCounter's scope

  return function () {
    count++; // the inner function still has access to `count`
    return count;
  };
}

const counter = createCounter(); // createCounter has already finished running
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

By the time counter() is called, createCounter() has already returned — normally, you'd expect its local variables to be gone. But the inner function that was returned still holds a live reference to count, so it keeps working. That's the whole idea: a closure keeps a variable alive for as long as something still has access to a function that can reach it.

Each call creates a new closure

Call createCounter() a second time and you get an entirely independent counter, with its own private count — the two closures don't share state. Every invocation of the outer function creates a fresh scope for the inner function to close over.

2. Lexical Scoping & the Scope Chain

Closures work because of lexical scoping — a function's scope is determined by where it is written in the source code, not by where or how it's later called. When JavaScript looks up a variable, it walks outward through each enclosing scope until it finds one, forming what's called the scope chain:

the scope chain
const globalValue = "global";

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

  function inner() {
    const innerValue = "inner";
    // inner() can see all three: innerValue, outerValue, AND globalValue
    console.log(innerValue, outerValue, globalValue);
  }

  inner();
}

outer(); // "inner outer global"

inner() is defined inside outer(), which is defined at the top level — that nesting, fixed at the moment the code was written, is exactly the chain JavaScript walks when resolving a variable name. It doesn't matter that outer and inner might be called from somewhere completely different; the scope chain is locked in by where the functions live in the code, not by the call site. That's the "lexical" part.

The lookup only goes outward, never inward

outer() above has no way to see innerValue — a variable declared inside a nested function is invisible to anything outside it. Scope lookup travels from the inside out, following the nesting as it was written, and stops the moment it finds a match.

3. Practical Closure Patterns

Two shapes cover most real uses of closures: hiding state that shouldn't be directly editable, and generating specialized functions from a shared template.

private state
function createBankAccount(startingBalance) {
  let balance = startingBalance; // not accessible from outside this function at all

  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) throw new Error("Insufficient funds");
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance);      // undefined -- there's no direct way in

balance can only be read or changed through the three methods returned alongside it — there's no property called balance on the returned object at all. This is how JavaScript approximates "private" fields without a real access-modifier keyword: the variable simply isn't reachable from outside the closure that captured it.

function factories
function makeMultiplier(factor) {
  return function (n) {
    return n * factor; // factor is captured once, at creation time
  };
}

const double = makeMultiplier(2);
const triple = makeMultiplier(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

double and triple are both built from the same makeMultiplier template, but each one closed over a different value of factor at the moment it was created — so they behave permanently differently from each other, without needing separate function definitions.

4. The Loop Pitfall

This is the single most common closure bug, and it's a fair interview question precisely because it looks so reasonable at first glance:

the bug: var inside a loop
for (var i = 1; i <= 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 100);
}

// Logs: 4, 4, 4 -- not 1, 2, 3!

var is function-scoped (Week 2), not block-scoped — so there is only one i for the entire loop, shared by all three callbacks. By the time any of the setTimeout callbacks actually runs, the loop has already finished and i has already reached 4. All three closures point at that same, final value.

the fix: let inside a loop
for (let i = 1; i <= 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 100);
}

// Logs: 1, 2, 3 -- each callback gets its own i

Switching to let is enough to fix it, because let creates a new binding of i for every iteration of the loop — each callback closes over its own separate copy, not one shared variable. This one behavioral difference between var and let is worth remembering on its own; it's the reason this bug simply can't happen with modern declarations.

5. IIFEs (Immediately Invoked Function Expressions)

Before let/const and ES modules existed, JavaScript had no built-in way to create an isolated scope outside of a function — so developers wrote a function and called it immediately, purely to get a private scope:

an IIFE
(function () {
  const secret = "not visible outside this function";
  console.log(secret);
})(); // the trailing () calls it immediately

console.log(typeof secret); // "undefined" -- it never leaked out

The wrapping parentheses around the function are required — without them, JavaScript tries to parse function () {'{'}...{'}'} as a function declaration, which needs a name and can't be called inline like this. Wrapping it in parentheses tells the parser to treat it as an expression instead, which can be invoked immediately with a trailing ().

You'll rarely write one on purpose today

Block-scoped let/const and ES modules (Week 9) solve the "avoid leaking into the global scope" problem IIFEs were built for, so new code rarely needs them. They're still worth recognizing on sight — you'll see them in older codebases and in some bundled library output.

6. Hands-on Exercise

Hands-on

Build a shopping cart with private state

Extend Week 2's cart calculator into a closure-based cart that hides its item list entirely.

Requirements:

  1. Write a function createCart() that keeps a private array of items — nothing outside the function should be able to reach that array directly.
  2. Return an object with three methods: addItem(name, price), removeItem(name), and getTotal() (reusing Week 2's reduce-based summing).
  3. Create two separate carts with createCart() and confirm that adding items to one never affects the other's total.
  4. Try accessing the item list directly from outside (e.g. cart.items) and confirm it's undefined.
  5. Add one comment explaining, in your own words, why this qualifies as a closure rather than just "an object with methods."
Hint

Model this directly on the createBankAccount example above — swap a single balance number for an array of {'{'} name, price {'}'} objects, and removeItem can use Array.prototype.filter to build a new array without the removed item.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does counter() still work after createCounter() has already returned?

The inner function returned by createCounter() forms a closure over count — it keeps a live reference to that variable, not a snapshot of its value. As long as something still holds a reference to the inner function (here, the counter variable), count stays alive in memory even though the outer function's own execution finished long ago.

Q2

What does "lexical" mean in "lexical scoping"?

A function's scope is fixed by where it is written in the source code — its physical nesting — not by where or how it's later called. This is why inner() nested inside outer() can always see outer()'s variables, no matter what code eventually calls inner() or from where.

Q3

A for (var i = 0; ...) loop with a setTimeout callback inside it logs the same final value every time. What's the one-word fix, and why does it work?

Replace var with let. var is function-scoped, so there's only one shared i for the whole loop — every callback closes over that same variable, and reads whatever value it holds once the loop finishes. let creates a brand-new binding of i on every iteration, so each callback closes over its own independent copy instead.

Q4

Why did older JavaScript code wrap logic in an IIFE, and why is that mostly unnecessary today?

Before block scoping and modules existed, a function was the only way to create a private scope and keep variables from leaking into (or colliding with) the global scope — so code was wrapped in a function and called immediately just to get that isolation. let/const (block scope) and ES modules (each module has its own scope by default) both solve that same problem natively now, so new code rarely needs the pattern.