Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables

Words
2792
Reading
13 min
Listen
Play
20d

Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables

js-banner.png

What will I learn

  • You will learn what scope is, and the three kinds JavaScript has: global, function, and block;
  • how the scope chain lets inner code see outer variables, but not the reverse;
  • what "lexical scope" means, and why it is decided by where you write code, not where you run it;
  • the Temporal Dead Zone in full, and why let/const protect you from a class of bugs;
  • how JavaScript's scope rules compare to Python, Rust, Go and C;
  • a first, honest look at closures, the idea the next phase is built on.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Node.js (20+) distribution, or just a modern browser console;
  • Episodes 1-9 read, so variables, blocks, and functions are familiar.

Difficulty

  • Beginner

Curriculum (of the Learn JS Series):

Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables

Solutions to Episode 9 Exercises

As always, we open with the worked solutions to last episode's three exercises. Do not just skim them -- type them out, run them, and hold the result against your own attempt. That comparison is where the understanding actually lands.

Exercise 1 - a rectangle area that defaults to a square:

function rectangleArea(width, height = width) {
  return width * height;
}
console.log(rectangleArea(4, 5)); // 20
console.log(rectangleArea(6));    // 36 - height defaulted to width

The insight: a default value can reference an earlier parameter, so height = width cleanly turns a missing height into "same as width", giving a square. The parameters evaluate left to right, which is precisely why height is allowed to lean on width.

Exercise 2 - the first truthy argument:

function firstTruthy(a, b, c) {
  return a || b || c || "none";
}
console.log(firstTruthy(0, "", "found")); // "found"
console.log(firstTruthy(0, "", null));     // "none"

The insight: || returns the first truthy operand (from episode 4), so chaining it walks left to right and stops at the first useful value, falling through to "none" only if every argument is falsy.

Exercise 3 - hoisting, demonstrated:

console.log(declared(3)); // 9 - declarations are hoisted, this works

function declared(n) {
  return n * n;
}

// const expressed = function (n) { return n * n; };
// console.log(expressed(3)); // would throw if called ABOVE this line (TDZ)

The insight: a function declaration is fully hoisted and callable before its line; a function stored in a const is not, because the binding sits in the Temporal Dead Zone until its assignment runs.

Which is the perfect bridge to today's topic, because to really understand hoisting, closures, and honestly about half of JavaScript's behaviour, you first need to understand scope. It is the invisible machinery under every line you have written so far, and once you can see it, a whole lot of "why did it do that?" turns into "of course it did that".

What scope is

Scope is the set of rules that decides where a variable is visible, that is, from which parts of your code you can refer to it by name. When you write console.log(x), JavaScript has to figure out which x you mean -- or whether x exists at all. Scope is the answer to that question, worked out for every single name you use.

JavaScript has three levels of scope:

  • Global scope: variables declared outside any function or block, visible everywhere.
  • Function scope: variables that live inside the function that declares them, and nowhere else.
  • Block scope: variables declared with let/const inside any { } live only inside that block.
const globalVar = "I am global"; // global scope

function outer() {
  const functionVar = "inside outer"; // function scope
  if (true) {
    const blockVar = "inside the if block"; // block scope
    console.log(blockVar);    // works
    console.log(functionVar); // works - outer scope is visible
    console.log(globalVar);   // works - global is visible
  }
  // console.log(blockVar); // ERROR (runtime): blockVar is not defined out here
}
outer();

Notice the direction, because it is the whole game: inner code can see outward, but outer code cannot see inward. blockVar is trapped inside its if block and does not exist outside it. Think of scopes as a set of one-way mirrors stacked inside each other -- you can always look out toward the world, never in toward someone else's private room.

A quick but important note on var: unlike let and const, var does not respect block scope. A var declared inside an if block leaks out to the whole surrounding function, which is one of the many reasons we retired it back in episode 2. When I say "block scope" in this episode, I mean the sane let/const behaviour.

The scope chain

When you use a variable, JavaScript looks for it in the current scope. If it is not there, it looks in the enclosing scope, then the one enclosing that, and so on outward until it reaches the global scope. This series of nested lookups is called the scope chain:

const level = "global";

function outer() {
  const outerLevel = "outer";
  function inner() {
    const innerLevel = "inner";
    // inner can reach ALL THREE, walking outward:
    console.log(innerLevel, outerLevel, level); // "inner" "outer" "global"
  }
  inner();
}
outer();

inner finds innerLevel in its own scope, outerLevel one level out, and level all the way up in the global scope. The search always goes outward, never inward, and -- this part matters -- it stops at the first match. If a variable is not found anywhere in the chain, you get a ReferenceError, JavaScript's way of saying "I looked everywhere I am allowed to look and this name simply does not exist here".

Having said that, the "stops at the first match" rule has a consequence worth pulling out on its own, because it is where a surprising number of bugs are born.

Shadowing

If an inner scope declares a variable with the same name as an outer one, the inner one shadows (hides) the outer one within that scope. The lookup stops at the nearest match and never even considers the outer variable:

const name = "global scipio";

function greet() {
  const name = "local scipio"; // shadows the global 'name' in here
  console.log(name);           // "local scipio"
}
greet();
console.log(name);             // "global scipio" - untouched outside

Shadowing is not an error, it is normal and often useful (a loop counter called i inside a function that also has an outer i, for instance). But be aware of it: inside greet, the name name refers to the local one, and the global is completely invisible there. Accidental shadowing -- reusing a name you forgot was already taken outside -- can cause genuinely confusing bugs, which is one more reason descriptive names earn their keep.

Lexical scope: decided by where you write, not where you run

Here is a subtle and crucial point, and it is the hinge the rest of the language swings on. JavaScript uses lexical scope (also called static scope), which means a function's scope is determined by where it is written in the source code, not by where or how it is called. You can read a function and know its scope just by looking at the nesting, without running a single line:

const message = "written here";

function makeReporter() {
  return function reporter() {
    console.log(message); // resolves via where reporter was WRITTEN
  };
}

const report = makeReporter();
report(); // "written here" - it uses the scope from its definition site

Even though we call report() far away from where message lives, reporter still sees message, because at the moment it was written, message was sitting in an enclosing scope. Lexical scope is a promise the language makes to you: the variables a function can reach are fixed by its position in the code, and they will not change based on who calls it or from where. Hold onto this, because it is the exact mechanism behind closures, which we reach at the end of today.

The alternative, by the way, is called dynamic scope, where a function would see whatever variables happened to exist at the call site. A handful of old languages worked that way and it was a nightmare to reason about -- you could never tell what a function would see without tracing every possible path that reached it. Lexical scope trades that chaos for predictability, and it is one of the quietly good decisions in JavaScript's design.

The Temporal Dead Zone, in full

We have hinted at the Temporal Dead Zone (TDZ) a few times now; let's nail it properly. Every let and const variable is hoisted to the top of its block -- JavaScript knows in advance that it will exist -- but it is left uninitialised until execution actually reaches its declaration line. The stretch from the top of the block down to that line is the TDZ, and touching the variable while it is in there throws immediately:

{
  // console.log(secret); // ERROR (runtime): Cannot access 'secret' before initialization
  const secret = 42;      // TDZ ends here
  console.log(secret);    // 42 - now it is initialized
}

Compare that to var, which is hoisted AND initialised to undefined, so instead of an error it silently hands you undefined:

{
  console.log(oldStyle); // undefined - no error, which quietly hides bugs
  var oldStyle = 42;
  console.log(oldStyle); // 42
}

See the difference in temperament? The TDZ is a safety feature, and a well-designed one. It converts "you used a variable before it was ready" from a silent undefined (a bug you will chase for an hour, because the wrong value drifts downstream and blows up somewhere else entirely) into a loud, immediate, pointed error (a bug you fix in a minute, because the message tells you exactly which name and exactly where). That is a fantastic trade, and it is a big part of why we banned var in episode 2.

One common question: "if let is hoisted too, why not just let me read it early like var?" Because reading a not-yet-initialised binding is almost never something you meant to do -- it is a mistake nearly every time. The language treats it as one on purpose. var pretending everything is fine is the bug; let refusing is the feature.

A first honest look at closures

Everything today has been building toward one idea, and I want to give you a real first look at it even though it gets a full episode of its own next phase. A closure happens when a function remembers and keeps access to the variables from the scope where it was defined, even after that outer scope has finished running and returned.

Because of lexical scope, an inner function carries its birthplace's variables around with it, like a backpack it never puts down. Watch closely:

function makeCounter() {
  let count = 0;            // lives in makeCounter's scope
  return function () {
    count = count + 1;      // the inner function still sees 'count'
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3 - 'count' survived, privately!

Think about what just happened. makeCounter has already returned -- its call is finished, its stack frame is, by every naive intuition, long gone -- and yet count is still alive, still remembered by the inner function. Each call to counter() sees and updates that same private count. That is a closure: a function plus the living variables from the scope where it was born.

And it is not a toy. This is how JavaScript does private state -- data that only one function can touch, with no way for outside code to reach in and corrupt it. Make a second counter and you will see they do not interfere:

const a = makeCounter();
const b = makeCounter();
console.log(a()); // 1
console.log(a()); // 2
console.log(b()); // 1 - b has its OWN private count, untouched by a

Two independent counters, two independent count variables, both invisible from the outside. If it feels a little magical right now, that is completely fine -- we will take it fully apart soon. For today, just notice that it falls straight out of the two rules you already learned: lexical scope decides what the inner function can see, and the scope chain keeps those variables reachable for as long as the function that closed over them is still alive. ;-)

How scope compares to Python, Rust, Go and C

Quite some of you arrived here from the Learn Python Series or the Learn Rust Series, so a glance sideways is worth it -- it shows which of JavaScript's scope choices are universal and which are its own flavour.

Python has function scope but, famously, no block scope. A variable created inside an if or a for is still visible after the block ends, which trips up people coming from JavaScript (and the other way around). Python also resolves names lexically, and it reaches into enclosing functions the same way JavaScript walks its scope chain:

# Python: the loop variable LEAKS out of the block (no block scope)
for i in range(3):
    pass
print(i)  # 2  - still alive here, unlike a JS let in a block

def make_counter():
    count = 0
    def inc():
        nonlocal count   # Python needs 'nonlocal' to REASSIGN an outer variable
        count += 1
        return count
    return inc

That nonlocal keyword highlights a real difference: in JavaScript, an inner function can reassign an outer variable with no ceremony at all (our count = count + 1 just worked). Python makes you declare the intent to write to an enclosing variable, otherwise the assignment would create a brand-new local instead. Different philosophy, same underlying closure machinery.

Rust is block-scoped like JavaScript's let, but takes it further: a block is an expression that can return a value, and variables inside it are dropped the moment the block ends. Rust also allows deliberate shadowing in the same scope, even changing the type, which is idiomatic rather than frowned upon:

fn main() {
    let x = 5;
    let x = x + 1;      // shadowing, on purpose - x is now 6
    {
        let x = x * 2;  // inner block shadows again - x is 12 in here
        println!("{}", x); // 12
    }
    println!("{}", x);  // 6 - the inner x is gone, block scope
}

Go is block-scoped as well, and its scope chain works much like the ones we have seen. Go's own twist is that a variable declared but never used is a compile error, not a warning -- the language is aggressive about not letting dead names linger, something JavaScript's dynamic nature can never enforce:

func main() {
    x := 5
    {
        x := 10 // a NEW x, scoped to this block (shadows the outer x)
        fmt.Println(x) // 10
    }
    fmt.Println(x) // 5
}

C, where a lot of this C-family syntax originally comes from, is block-scoped too, but it has no closures at all -- a C function cannot capture and carry local variables from an enclosing function the way JavaScript does, because C functions are not nested and not first-class in that sense. That single capability, closures, is a large part of what makes JavaScript feel so different from C despite the shared braces.

The throughline: almost every modern language is lexically scoped, and most are block-scoped (Python being the odd one out). Where JavaScript really distinguishes itself is the effortless closure -- inner functions capturing outer variables with zero keywords and zero fuss. Knowing where JS sits on that spectrum turns its rules from "arbitrary syntax" into "a set of deliberate trade-offs", which is exactly the mindset that makes the rest of the series click.

Putting it together

Let's close with a small program that leans on nearly everything from today at once: block scope, the scope chain, lexical scope, and a closure holding private state. We will build a tiny bank account whose balance cannot be touched from the outside except through the methods we hand back -- the closest thing plain JavaScript has to a private field, and a pattern you will meet constantly in real code (it is the old "module pattern").

function makeAccount(startingBalance = 0) {
  let balance = startingBalance; // private - lives only in this scope

  function deposit(amount) {
    if (amount <= 0) return "deposit must be positive";
    balance = balance + amount;
    return balance;
  }

  function withdraw(amount) {
    if (amount > balance) return "insufficient funds"; // guard clause, from ep9
    balance = balance - amount;
    return balance;
  }

  function getBalance() {
    return balance;
  }

  return { deposit, withdraw, getBalance }; // hand back only the doors we allow
}

const account = makeAccount(100);
console.log(account.getBalance());  // 100
console.log(account.deposit(50));   // 150
console.log(account.withdraw(30));  // 120
console.log(account.withdraw(999)); // "insufficient funds" - guard caught it
console.log(account.balance);       // undefined - there is NO public 'balance'!

Trace what makes this work. balance is declared with let inside makeAccount, so by block/function scope it is invisible to any code outside. The three inner functions are written inside makeAccount, so by lexical scope they can all see balance, and by closure they keep seeing it long after makeAccount has returned. We hand back an object containing those functions -- and only those functions -- so the outside world can deposit, withdraw, and read, but can never assign to balance directly. That last line, account.balance, prints undefined precisely because there is no public balance; the real one is sealed inside the closure.

That is genuinely useful design, and notice it is not built from some special "private" feature -- it falls straight out of scope rules you now understand. Every method shares the same balance because they were all born in the same scope, while a second makeAccount(500) would get its own completely separate balance. Scope is not an academic topic; it is the tool you reach for whenever you want data that is protected, shared exactly where it should be, and nowhere else.

Try it yourself

  1. Write a function outerFn that declares a const secret = "hidden" and contains an innerFn that logs secret. Call innerFn from inside outerFn and confirm it works. Then try to log secret from outside outerFn and observe the error. Explain the direction of the scope chain in one sentence.
  2. Write code that deliberately shadows a global const total = 100 inside a function with a local const total = 5, logs the local value inside, and logs the global value outside, proving the two do not interfere.
  3. Build your own makeGreeter(name) that returns a function taking a greeting and returning "greeting, name!", so const hi = makeGreeter("scipio"); hi("Hello") gives "Hello, scipio!". Explain which variable the returned function is closing over.

So what did we actually cover?

  • Scope is the rulebook for where a variable is visible; JavaScript has global, function, and block scope (let/const respect blocks, var does not).
  • The scope chain resolves names by searching outward from the current scope and stopping at the first match; inner code sees outer variables, never the reverse.
  • Shadowing is when an inner variable hides an outer one of the same name within its scope.
  • JavaScript is lexically scoped: a function's accessible variables are fixed by where it is WRITTEN, not where it is called.
  • The Temporal Dead Zone makes let/const throw if used before their declaration line, turning silent undefined bugs into loud, findable errors.
  • A closure is a function that keeps access to the variables of the scope it was born in, even after that scope has returned -- the foundation of private state in JavaScript.
  • Most modern languages are lexically and block-scoped; JavaScript's standout trait is the effortless closure, which C does not have at all.

That wraps up the core of Phase 1: you now understand how JavaScript stores data, makes decisions, repeats work, defines functions, and finds variables. Next episode we start Phase 1's home stretch with arrays, the workhorse data structure, and the methods that make them a genuine joy to work with.

That is scope demystified, see you next time.

scipio@scipio

Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaS... | Ecency