Scope & Hoisting
Scope decides where a variable can be seen. Hoisting describes what JavaScript does with declarations before your code runs. Together they explain a lot of errors that look mysterious at first — "is not defined", "cannot access before initialization" — and they're the foundation for closures in the next lesson.
Three kinds of scope
- Global scope — variables declared outside any function or block. Visible everywhere.
- Function scope — variables declared inside a function. Visible only inside that function.
- Block scope —
letandconstdeclared inside{ }(anif, a loop, or a bare block). Visible only inside that block.
const appName = "Notes"; function showTitle() { const prefix = "★"; if (true) { const suffix = "!"; console.log(prefix, appName, suffix); } console.log(typeof suffix); } showTitle(); console.log(typeof prefix);
★ Notes ! undefined undefined
typeof is used here because it doesn't throw for undeclared names. Reading suffix directly outside its block would be a ReferenceError.
The scope chain
When code uses a name, JavaScript looks in the current scope, then the enclosing scope, then the next one out, all the way to the global scope. Inner code can see outer variables; outer code can't see inner ones:
const level = "global"; function outer() { const level = "outer"; function inner() { console.log("inner sees:", level); } inner(); } outer(); console.log("global sees:", level);
inner sees: outer global sees: global
The inner level shadows the global one: the nearest declaration wins. Shadowing is legal but can be confusing, so avoid reusing names unnecessarily.
Scope is decided by where code is written, not where it's called from. This is called lexical scope.
Avoid global variables
Every script on a page shares the global scope. Global variables can be overwritten by other scripts or browser extensions, and make it hard to know what depends on what. Keep variables inside functions and modules (lesson 55) — module files have their own scope, so their top-level variables aren't global at all.
total = 5 with no let/const) creates a global variable in sloppy mode. Strict mode, which modules and classes use automatically, turns this into an error instead (lesson 56).Hoisting
Before running a scope's code, JavaScript scans it for declarations and sets them up first. This is called hoisting, and different declarations are hoisted differently:
| Declaration | Before its line runs… |
|---|---|
function f() {} | Fully available — you can call it |
var x | Exists, with the value undefined |
let x, const x, class X | Exists, but any access is an error (the "temporal dead zone") |
console.log(sayHello()); console.log(oldVar); function sayHello() { return "hello"; } var oldVar = "set later";
hello undefined
console.log(total);
let total = 10;
Uncaught ReferenceError: Cannot access 'total' before initialization
The error message gives it away: total exists (it was hoisted), but it can't be used yet. This "temporal dead zone" is deliberate — it turns silent undefined bugs from the var era into clear errors.
Function expressions aren't hoisted like declarations
console.log(typeof square); console.log(square(3)); const square = n => n * n;
Uncaught ReferenceError: Cannot access 'square' before initialization
Only the const square declaration is hoisted (into the dead zone), not the function assigned to it. Function declarations are the only thing you can use before their line.
Block scope in loops
With let, each loop iteration gets its own copy of the loop variable. This matters when the loop creates functions that run later:
const withLet = []; for (let i = 0; i < 3; i++) { withLet.push(() => i); } const withVar = []; for (var j = 0; j < 3; j++) { withVar.push(() => j); } console.log(withLet.map(f => f())); console.log(withVar.map(f => f()));
[ 0, 1, 2 ] [ 3, 3, 3 ]
All three var functions share a single j, which is 3 by the time they run. This classic bug is one more reason to avoid var.