What you'll be able to do

By the end of this page you should be able to explain scope as “where a name is visible,” use block scope with let/const on purpose, and predict whether a line will see a variable, throw a TDZ error, or (with old var) show undefined.

Previous post: arrow functions — when to use them.

Who this is for

  • People who get ReferenceError: Cannot access before initialization
  • Beginners who log a variable and see undefined and think the computer is broken
  • Anyone who heard “hoisting” and got a myth instead of a working model

You can skip this if you already keep let/const inside the block that needs them and never rely on var leaking. Come back when a name is “missing” or “too early.”

Scope = where a name is visible

A scope is a region of the program where a binding (a name) can be used.

const outer = "outside";

function demo() {
  const inner = "inside";
  console.log(outer);
  console.log(inner);
}

demo();
// console.log(inner); // Error: inner is not defined here

Scenario A — inside the function. inner works. outer also works because nested scopes can read outward (the chain looks up).

Scenario B — outside the function. inner is invisible. That is scope doing its job: local names stay local.

Block scope with let and const

Curly braces of if, for, and plain {} create a block. let and const live in that block.

if (true) {
  const message = "hi";
  console.log(message);
}
// console.log(message); // Error

Scenario A — loop variable. for (let i = 0; i < 3; i++) keeps i in the loop. Outside, i is not available (with let).

Scenario B — accidental reuse. Declaring const name inside an if does not overwrite a name outside — they are different bindings in different scopes (as long as you are not in the same block).

var is function-scoped (or global), not block-scoped. var inside an if can still be seen later in the same function. Prefer let/const so blocks mean what they look like.

Temporal dead zone (TDZ)

From the start of a block until the let/const line runs, the name exists but you must not touch it. That gap is the temporal dead zone.

console.log(score); // ReferenceError (TDZ)
const score = 10;

Scenario A — call above const. Same as function expressions last posts.

Scenario B — typeof surprise. typeof score in the TDZ also throws for let/const (unlike an undeclared global, where typeof is "undefined"). Do not use typeof as a “does this exist yet?” check for let/const before their line.

Hoisting without the myth

The engine prepares declarations before running your statements. That is why people say names are “hoisted.”

KindWhat you can do above its line
function declarationCall it (in that scope)
let / constNo — TDZ until the line
varName exists as undefined until assigned — easy to misuse

Scenario A — var looks “undefined.”

console.log(count); // undefined
var count = 1;

The name was prepared; the value 1 was not assigned yet. That undefined is not a mystery value from nowhere — it is “declared, not assigned.”

Scenario B — let fails loud. Better for beginners: you get an error instead of a quiet wrong value.

Shadowing

An inner name can hide an outer one with the same spelling:

const label = "outer";

function show() {
  const label = "inner";
  console.log(label);
}

show();
console.log(label);

Logs inner, then outer.

Scenario A — intentional. A parameter name inside a function hides an outer name.

Scenario B — accidental. You think you updated the outer variable but declared a new const inside. No error — wrong data. Prefer clear names over reuse when learning.

A tiny practice file

Save as scope.html. Click to see outer vs inner and a TDZ note.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Scope</title>
  </head>
  <body>
    <button id="go">Run</button>
    <pre id="out">Result will show here.</pre>
    <script>
      const outer = "outer";

      function demo() {
        const inner = "inner";
        return "outer=" + outer + ", inner=" + inner;
      }

      document.getElementById("go").addEventListener("click", function () {
        const lines = [];
        lines.push(demo());
        lines.push("outer still: " + outer);
        lines.push("inner is not visible out here (scope).");
        lines.push("TDZ: do not use let/const above their line.");
        document.getElementById("out").textContent = lines.join("\n");
      });
    </script>
  </body>
</html>

In the console, try console.log(x); const x = 1; in a scratch script — that is TDZ. Then try the same with var and compare.

Mistakes I see a lot

1. Expecting let to work like var above its line. TDZ throws instead of undefined.

2. Declaring inside a block and using it outside. Scope ended with the }.

3. Assuming hoisting “moves your code” in the file. It is engine preparation, not a rewrite you should depend on for style.

4. Shadowing without noticing. Inner const total never updates outer total.

5. Using var to “fix” TDZ. You trade a clear error for a sneaky undefined. Prefer fixing order with let/const.

What to try before the next post

  1. Log a const above its line; read the error.
  2. Put let i in a for and try console.log(i) after the loop.
  3. Shadow a name on purpose; log inner and outer.
  4. Build the tiny HTML file.

Next in this series: default parameters and rest — fallbacks when an argument is missing, and gathering leftover arguments into an array.

Try this next outside the series

Functions matter more once they stop being textbook examples and start shaping reusable app code.

  • Node.js async patterns — see functions return promises, accept callbacks, and coordinate real work
  • Diff Checker — compare two function versions before you keep a refactor