JavaScript Scope

6 minute read

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 let and const exclusively; var is 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.

← Previous: JS Arrow Functions Next: JS Closures →

Updated: