JavaScript Scope
Scope is the set of rules that decides which names are visible from where in your code. Get scope right and your code stays predictable; get it wrong and you’ll fight phantom bugs where variables seem to “leak” into the wrong place. Modern JavaScript has three scope flavours — global, function, and block — plus the lexical (write-time) rule that ties them all together.
The Three Scopes
| Scope | Created by | Lifetime | Variables declared with |
|---|---|---|---|
| Global | the script / module | program lifetime | let / const / var at top level |
| Function | calling a function | until function returns (longer if a closure holds it) | any inside the function |
| Block | a { … } (if, for, plain block) |
until the block ends | let / const only |
const global = "I'm everywhere"; // global
function outer() {
const fn = "function-scoped";
if (true) {
const block = "block-scoped"; // gone after the }
console.log(block); // "block-scoped"
}
// console.log(block); // ❌ ReferenceError
}
Block Scope — The let/const Rule
Anything inside { … } is its own block scope when declared with let or const:
{
let x = 1;
console.log(x); // 1
}
console.log(x); // ❌ ReferenceError
if (true) {
const y = 2;
}
console.log(y); // ❌ ReferenceError
for (let i = 0; i < 3; i++) {
// each iteration is a NEW binding of i
}
console.log(i); // ❌ ReferenceError
var ignores block scope — it’s function-scoped only:
function example() {
if (true) {
var x = 10; // function-scoped, leaks out
let y = 20; // block-scoped
}
console.log(x); // 10
console.log(y); // ❌ ReferenceError
}
Tip: This is the headline reason to never use var in new code. Block scope is what you almost always want.
Function Scope
Each function call creates a fresh scope. Parameters and locally-declared variables disappear when the function returns (unless captured by a closure — chapter 31).
function outer() {
const a = 1;
function inner() {
const b = 2;
console.log(a, b); // 1 2 — inner sees outer's `a`
}
inner();
// console.log(b); // ❌ ReferenceError — b lives in inner only
}
outer();
The Scope Chain
When you reference a name, the engine walks outward until it finds it:
┌───────────────────────┐
│ Global scope │
│ const colour = "red"│
│ │
│ ┌───────────────┐ │
│ │ outer scope │ │
│ │ const x = 1 │ │
│ │ │ │
│ │ ┌───────────┐ │ │
│ │ │ inner │ │ │
│ │ │ use(x) │ │ ──┼──► looks up `x` in inner → not found
│ │ │ use(c) │ │ │ → looks in outer → x = 1
│ │ └───────────┘ │ │ → looks up `colour` → outer (no) → global → "red"
│ └───────────────┘ │
└───────────────────────┘
const colour = "red";
function outer() {
const x = 1;
function inner() {
console.log(x, colour); // 1 "red"
}
inner();
}
outer();
If a name is not found in any scope, you get a ReferenceError. (Unless you assign without declaration in non-strict mode — that creates a global, which is even worse.)
Lexical Scope — Where Functions Are Defined Wins
JavaScript uses lexical (static) scope: a function’s accessible variables are determined by where it was written, not where it’s called from.
const x = "outer";
function showX() {
console.log(x);
}
function caller() {
const x = "inner";
showX(); // "outer" — `showX` was defined where x was "outer"
}
caller();
Compare to dynamic scope (some Lisps, shell scripts), where showX would have printed "inner". JavaScript is lexical — period.
Variable Shadowing
An inner declaration with the same name shadows the outer one — the outer is invisible until the inner block ends:
const x = "outer";
function inner() {
const x = "inner"; // shadows outer x
console.log(x); // "inner"
}
inner();
console.log(x); // "outer"
Shadowing is legal but error-prone. Most linters warn about it. If you find yourself shadowing, rename one variable.
Global Scope — Be Careful
In a browser script (non-module), the global scope is shared across all <script> tags. In Node.js with modules, “global” is module-local. Modern modules (<script type="module">, .mjs, import/export) don’t pollute the global object — they’re properly scoped.
// Browser, non-module
var x = 1;
window.x; // 1 ← attached to window
// Module
let y = 1;
window.y; // undefined ← not attached
Warning: Assigning to an undeclared name in non-strict mode silently creates a global. 'use strict' (and ES modules) make this a ReferenceError — always run in strict mode.
function bad() {
oops = 5; // no var/let/const — accidentally globally created
}
bad();
console.log(oops); // 5 in non-strict, ReferenceError in strict
Temporal Dead Zone (TDZ)
let and const are hoisted to the top of their block, but they can’t be read until the line that declares them runs. The window of “exists but unusable” is the temporal dead zone.
console.log(x); // ❌ ReferenceError — TDZ
let x = 5;
console.log(y); // undefined — `var` is hoisted AND initialised
var y = 5;
This is a feature, not a bug — it forces you to declare before use.
Hoisting Quick Recap
| Declaration | Hoisted? | Initialised? | Behaviour before declaration |
|---|---|---|---|
var x = 1 |
✅ | to undefined |
undefined |
let x = 1 |
✅ | ❌ | TDZ → ReferenceError |
const x = 1 |
✅ | ❌ | TDZ → ReferenceError |
function f() {} |
✅ | to the function | callable |
class C {} |
✅ | ❌ | TDZ |
Full chapter on hoisting is 32.
A Realistic Example — IIFE for Module-Like Encapsulation
Before ES modules, the IIFE pattern (Immediately Invoked Function Expression) was the standard way to keep helpers private:
const counter = (function () {
let count = 0; // private — block-scoped to the IIFE
return {
increment: () => ++count,
decrement: () => --count,
value: () => count,
};
})();
counter.increment();
counter.increment();
counter.value(); // 2
counter.count; // undefined — actually private
In modern code you’d use a real module file or a class. But the pattern still appears in build outputs, polyfills, and old codebases — it’s worth recognising.
Common Mistakes
// ❌ Loop var leaking with var (chapter 26 closure bug)
for (var i = 0; i < 3; i++) { /* ... */ }
console.log(i); // 3 — leaked from the loop
// ✅
for (let i = 0; i < 3; i++) { /* ... */ }
console.log(i); // ❌ ReferenceError — properly scoped
// ❌ Using a variable before its `let` declaration
console.log(x); // ReferenceError (TDZ)
let x = 1;
// ❌ Accidental global
function bad() {
total = 0; // no declaration → global in non-strict
}
// ✅ Strict mode + always declare
'use strict';
function good() {
let total = 0;
}
// ❌ Unintended shadowing
const user = "Ada";
function fetchUser() {
const user = "Lin"; // shadows — call sites might expect "Ada"
}
Best Practices Checklist
- ✅ Always run in strict mode (modules are strict by default)
- ✅ Use
letandconstexclusively;varis legacy - ✅ Declare variables at the smallest scope they’re needed in
- ✅ Avoid shadowing — rename if you find yourself doing it
- ✅ Prefer modules (
import/export) over IIFEs in new code - ❌ Don’t rely on hoisting — declare before use
- ❌ Don’t assign to a name without declaring it
- ❌ Don’t expose internals as global — keep them module-private
What’s Next?
You know where variables live. The natural next step: closures — what happens when a function “remembers” the variables of the scope it was created in, even after that scope is gone. It’s the single most powerful idea in JavaScript.