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
undefinedand 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.”
| Kind | What you can do above its line |
|---|---|
function declaration | Call it (in that scope) |
let / const | No — TDZ until the line |
var | Name 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
- Log a
constabove its line; read the error. - Put
let iin aforand tryconsole.log(i)after the loop. - Shadow a name on purpose; log inner and outer.
- 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