Перейти к содержимому
Clinford Jordano Valencia
Все статьи

JavaScript

Scope, Hoisting and the Temporal Dead Zone in JavaScript

The five kinds of scope in JavaScript, how name lookup works, what hoisting really does to var, let, const, functions and classes, and why the temporal dead zone exists.

  • 9 мин чтения

Эта страница пока недоступна на русском языке, поэтому вы читаете английский оригинал.

Scope answers one question: where can a name be used? In JavaScript the answer depends on where the code is written, not where it runs, and lookup always walks outward from the innermost scope. Hoisting answers a second question: when does a name start to exist? Every declaration’s binding is created when its scope starts, before any line runs, but var, functions, let, const and class start in different states. The gap where a let, const or class exists but can’t be used yet is the temporal dead zone (TDZ). This post covers all three, with the rules, diagrams and the traps they explain.

Why It Matters

Scope and hoisting explain a surprising share of everyday bugs:

  • a variable that is undefined even though it was assigned “above”
  • a ReferenceError that appears after an innocent refactor
  • a loop whose callbacks all see the last value
  • a global variable created by a typo

They are also the foundation for closures, which are just functions that keep their scope alive.

The Kinds of Scope

ScopeCreated byWhat lives there
GlobalThe page or the Node.js processTop-level declarations of classic scripts; built-ins such as Math and globalThis
ModuleEach ES module file (and each CommonJS file)Top-level declarations of that file, private unless exported
FunctionEach call of a functionParameters, var declarations and inner functions
BlockAny { … }: if, for, while, switch, try/catch or a bare blocklet, const and class declared inside it
Special casescatch (error), class bodies, and named function expressionsThe catch parameter, private names (#count), the function’s own name
Global scope: globalThis, Math, fetch …Module scope: const API_URLFunction scope: loadUser(id)Block scope: if (…) { … }const userlookup walks outwardblock → function→ module → global
Scopes nest the way code is written. Lookup starts in the innermost scope and walks outward; it never looks inward.
const API_URL = '/api'; // module scope
 
export async function loadUser(id) {
  // function scope: id
  if (id) {
    const user = await fetch(`${API_URL}/users/${id}`); // block scope: user
    return user.json();
  }
  // user doesn’t exist here
}

A few details that matter in practice:

  • Global is different in scripts and modules. In a classic <script>, a top-level var or function declaration becomes a property of window, so separate scripts can overwrite each other. A top-level let or const is global but not a property of window. In an ES module, top-level declarations are private to the module.
  • Each call gets a new function scope. Calling a function twice creates two independent sets of variables, which is what makes closures work.
  • Assigning to an undeclared name throws a ReferenceError in strict mode (modules and classes are always strict). In old sloppy scripts it silently creates a global variable, so a typo like totl = 5 becomes a bug far away.

Shadowing

A name declared in an inner scope shadows (hides) the same name from outer scopes, for the whole inner scope:

const label = 'outer';
function show() {
  const label = 'inner'; // shadows the outer label inside show
  return label;
}
show(); // 'inner'
label; // 'outer'

Shadowing is legal and sometimes useful, but it is a common source of confusion; linters can flag it (no-shadow).

Hoisting: What Actually Happens

When JavaScript enters a scope, it first creates a binding for every declaration in that scope, and only then runs the code line by line. That first step is what people call hoisting: nothing is physically moved, but every name exists from the start of its scope. What differs is the binding’s initial state:

DeclarationScopeState before its line runsRedeclare in same scope?
function f() {}Function or blockReady: the whole functionIn functions and scripts (a later one wins)
var xFunction (or global)undefinedYes, silently
let xBlockUninitialized (TDZ)No, SyntaxError
const xBlockUninitialized (TDZ)No, SyntaxError
class C {}BlockUninitialized (TDZ)No, SyntaxError
import x from '…'ModuleLinked before any module code runsNo, SyntaxError
sayHi(); // 'hi': function declarations are ready
function sayHi() {
  return 'hi';
}
 
console.log(count); // undefined: var starts as undefined
var count = 1;
 
sayBye(); // TypeError: sayBye is not a function (it is still undefined)
var sayBye = function () {
  return 'bye';
};

The last case trips people up: only the var sayBye binding is hoisted, not the function assigned to it, so the call meets undefined.

The Temporal Dead Zone

let, const and class bindings exist from the start of their block, but they stay uninitialized until their declaration line runs. Reading or writing them in that window throws a ReferenceError. The window is called the temporal dead zone:

block starts: binding for x createdlet x = 1; runsTDZ: x exists, using it throwsx can be usedconsole.log(x) → ReferenceErrorconsole.log(x) → 1
The temporal dead zone lasts from the start of the block until the declaration runs. It is about time, not position.

“Temporal” means it depends on when code runs, not where it is written. A function may mention a const declared below it, as long as the function is called after the declaration has run:

function getRate() {
  return rate; // fine to write, the question is when it runs
}
// getRate(); // here it would throw: rate is still in the TDZ
const rate = 0.2;
getRate(); // 0.2

Where the TDZ Surprises People

An inner declaration shadows from the top of its block, even on lines above it:

const theme = 'light';
{
  console.log(theme); // ReferenceError, not 'light'
  const theme = 'dark';
}

The inner theme exists from the start of the block, so the outer one is already hidden, and the inner one isn’t initialized yet.

typeof is no longer always safe:

typeof notDeclaredAnywhere; // 'undefined'
typeof later; // ReferenceError: Cannot access 'later' before initialization
let later = 1;

Default parameters are evaluated left to right, each in the TDZ of the ones after it:

function range(start = end - 10, end = 100) {
  return [start, end];
}
range(1); // [1, 100]
range(); // ReferenceError: Cannot access 'end' before initialization

Classes are not usable before their declaration, unlike function declarations:

new Invoice(); // ReferenceError
class Invoice {}

A declaration can’t use itself. The right-hand side runs while the name is still in its TDZ:

let total = total + 1; // ReferenceError: Cannot access 'total' before initialization

All case clauses of a switch share one block, so a let in one case is in the TDZ for the others:

switch (step) {
  case 0:
    let message = 'start';
    break;
  case 1:
    message = 'next'; // ReferenceError when step is 1: the let line never ran
}

Wrap each case in its own braces (case 0: { … }) to give it its own block.

A hoisted function can still hit the TDZ, because calling it early runs its body early:

init(); // ReferenceError: Cannot access 'config' before initialization
const config = { retries: 3 };
function init() {
  return config.retries;
}

Class order matters: class Admin extends User {} throws if User is declared further down, because the extends expression runs when the class is defined.

Why the TDZ Exists

  • It makes errors loud. With var, reading a variable too early silently gives undefined, and the bug surfaces later as NaN or a missing value. With let and const, the same mistake throws at the exact line.
  • It makes const meaningful. A constant that could be observed as undefined before its assignment would not really be constant.

var, let and const Side by Side

varletconst
ScopeFunctionBlockBlock
Before its lineundefinedTDZ (throws)TDZ (throws)
ReassignYesYesNo (the binding; objects inside can still change)
Redeclare in same scopeYes, silentlyNoNo
Top level of a classic scriptBecomes a window propertyGlobal, not on windowGlobal, not on window
In a for loopOne variable for the whole loopA fresh binding per iterationA fresh binding per iteration (for for…of)
Declared without a valueYes, starts as undefinedYes, starts as undefinedNo: SyntaxError: Missing initializer in const declaration
As a classic for counter (i++)WorksWorksTypeError: Assignment to constant variable
Inside switch casesShared by the whole functionShared by all cases (TDZ traps)Shared by all cases (TDZ traps)

The practical rule: use const by default, let when you must reassign, and var only when maintaining old code. Note that const stops reassignment of the variable, not mutation of the object it points to; see the hidden cost of mutable state.

How This Connects to Closures

A function remembers the scope it was written in. When it runs, its name lookups walk that scope chain, even if the outer function has already returned. That is a closure, and it is why the loop below behaves differently with var and let:

for (var i = 0; i < 3; i++) setTimeout(() => console.log('var', i));
for (let j = 0; j < 3; j++) setTimeout(() => console.log('let', j));
// var 3, var 3, var 3, let 0, let 1, let 2

With var there is one i for the whole function, and all three callbacks share it. With let each iteration gets its own j. What happens when a function runs covers closures, execution contexts and the call stack in depth.

Going Deeper: Where the Rules Bend

Most code never meets these cases, but they explain odd behaviour in old scripts, bundled code and interview questions.

Functions Declared Inside Blocks

The specification has a compatibility appendix (Annex B) for code written before ES2015. In sloppy scripts, a function declared inside a block also leaks out to the enclosing function, like a var; in strict code (every module and class) it stays inside the block:

if (true) {
  function hello() {
    return 'hi';
  }
}
console.log(typeof hello);
// 'function' in a sloppy script, 'undefined' in an ES module

The same source file can therefore behave differently depending on how it is loaded. Prefer const hello = () => … inside blocks, or declare the function at the top level.

Dynamic Scope: eval and with

Lexical scope is what lets engines (and you) know where every name comes from by reading the code. Two old features break that:

  • Direct eval can declare new var variables in the surrounding function in sloppy mode, so no name lookup in that function can be resolved ahead of time.
  • with (obj) puts an object’s properties into the scope chain, so x might mean obj.x or an outer variable depending on run-time data.

Both disable optimizations in the surrounding code and are forbidden or restricted in strict mode (with is a syntax error). Linters flag them; there is almost always a clearer alternative, such as a lookup table instead of eval, or destructuring instead of with.

Loops and Per-Iteration Bindings

A for (let i = …) loop creates a fresh copy of i for every iteration, which is what closures capture. That is also why const works in for…of, where each iteration gets a new binding that is never reassigned, but throws in a classic for (const i = 0; i < n; i++): the i++ tries to assign to a constant (TypeError: Assignment to constant variable).

What Tools Do With Scope

Scope is static, so tools can analyse it without running code:

  • Bundlers rename and inline variables safely.
  • Minifiers shorten local names but never globals.
  • Linters report unused or undeclared variables (no-unused-vars, no-undef) and shadowing (no-shadow).

Code that relies on eval, with or implicit globals defeats all of them.

Test Yourself

Predict the output, then open the answer. Every answer was checked by running the code.

Задача 1

`var` Inside a Function

var level = 'global';
function check() {
  console.log(level);
  var level = 'local';
}
check();
Показать ответ

undefined

  • The var level inside check is hoisted to the top of the function and shadows the global one.
  • Its assignment hasn’t run yet when console.log executes, so it is still undefined.

Задача 2

The Same Thing With `let`

let mode = 'global';
function check() {
  console.log(mode);
  let mode = 'local';
}
check();
Показать ответ

ReferenceError: Cannot access 'mode' before initialization

  • The inner mode shadows the outer one from the start of the function.
  • At the console.log line it is in its temporal dead zone.

Задача 3

Which One Is Hoisted?

console.log(typeof declared, typeof assigned);
function declared() {}
var assigned = () => {};
Показать ответ

function undefined

  • A function declaration is hoisted complete.
  • For var assigned, only the binding is hoisted, with the value undefined; the arrow function is assigned when that line runs.

Задача 4

Block Scope

if (true) {
  var fromVar = 'var';
  let fromLet = 'let';
}
console.log(fromVar, typeof fromLet);
Показать ответ

var undefined

  • var ignores blocks and belongs to the surrounding function (or module), so it is visible after the if.
  • fromLet belongs to the block. Outside it the name doesn’t exist at all, so typeof returns 'undefined' instead of throwing.

Задача 5

Default Parameters

function size(width = height, height = 10) {
  return width * height;
}
console.log(size(2));
console.log(size());
Показать ответ

20, then ReferenceError: Cannot access 'height' before initialization

  • With an argument, width doesn’t need its default.
  • Without one, the default for width runs while height is still in its temporal dead zone.

Key Takeaways

  • Scope is lexical: where code is written decides what it can see, and lookup walks outward from block to function to module to global.
  • JavaScript has global, module, function and block scope; var ignores blocks, let, const and class respect them.
  • Hoisting creates every binding when its scope starts: functions are ready, var is undefined, and let, const and class are uninitialized.
  • The temporal dead zone is the time between a block starting and a let, const or class declaration running; using the name in it throws.
  • Use const by default and let when you reassign; the TDZ turns “used too early” bugs into loud errors.

Sources

Теги

Частые вопросы

Are let and const hoisted?

YesIn the sense that their bindings exist from the start of the scope, which is why an inner let can shadow an outer variable even on lines above it. Unlike var, they are not initialized until the declaration runs, so using them earlier throws a ReferenceError.

Should I ever use var?

NoIn new code, almost never. let and const are block-scoped, can’t be redeclared by accident, and give each loop iteration its own binding. var is still worth understanding because older code and some generated code use it.

Why does typeof throw for some undeclared names?

typeof on a name that doesn’t exist anywhere returns 'undefined'. typeof on a let, const or class that exists but is still in its temporal dead zone throws, because the name is declared, just not initialized yet.

Is shadowing a variable bad?

NoNot always, but it often confuses readers, because the same name means different things in nested scopes. Many teams enable the no-shadow lint rule and pick distinct names instead.

Why does const work in for…of but not in a classic for loop?

for…of creates a new binding for each iteration and never reassigns it. A classic for loop updates its counter with i++, which is an assignment and throws for a const.

Why should I avoid eval and with?

They make scope dynamic: names can no longer be resolved by reading the code, which disables engine optimizations and confuses tools and readers. with is forbidden in strict mode.

How do I avoid creating global variables by accident?

Use ES modules (always strict), keep code inside functions or modules, and enable the no-undef lint rule. In strict mode, assigning to an undeclared name throws instead of creating a global.

What happens to functions declared inside an if block?

In strict code (modules and classes) they are scoped to the block. In old sloppy scripts they also leak to the enclosing function. Use a const arrow function inside blocks to avoid the difference.