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 Min. Lesezeit
Diese Seite gibt es noch nicht auf Deutsch, deshalb liest du das englische Original.
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
undefinedeven though it was assigned “above” - a
ReferenceErrorthat 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
| Scope | Created by | What lives there |
|---|---|---|
| Global | The page or the Node.js process | Top-level declarations of classic scripts; built-ins such as Math and globalThis |
| Module | Each ES module file (and each CommonJS file) | Top-level declarations of that file, private unless exported |
| Function | Each call of a function | Parameters, var declarations and inner functions |
| Block | Any { … }: if, for, while, switch, try/catch or a bare block | let, const and class declared inside it |
| Special cases | catch (error), class bodies, and named function expressions | The catch parameter, private names (#count), the function’s own name |
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-levelvaror function declaration becomes a property ofwindow, so separate scripts can overwrite each other. A top-levelletorconstis global but not a property ofwindow. 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
ReferenceErrorin strict mode (modules and classes are always strict). In old sloppy scripts it silently creates a global variable, so a typo liketotl = 5becomes 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:
| Declaration | Scope | State before its line runs | Redeclare in same scope? |
|---|---|---|---|
function f() {} | Function or block | Ready: the whole function | In functions and scripts (a later one wins) |
var x | Function (or global) | undefined | Yes, silently |
let x | Block | Uninitialized (TDZ) | No, SyntaxError |
const x | Block | Uninitialized (TDZ) | No, SyntaxError |
class C {} | Block | Uninitialized (TDZ) | No, SyntaxError |
import x from '…' | Module | Linked before any module code runs | No, 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:
“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.2Where 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 initializationClasses 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 initializationAll 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 givesundefined, and the bug surfaces later asNaNor a missing value. Withletandconst, the same mistake throws at the exact line. - It makes
constmeaningful. A constant that could be observed asundefinedbefore its assignment would not really be constant.
var, let and const Side by Side
var | let | const | |
|---|---|---|---|
| Scope | Function | Block | Block |
| Before its line | undefined | TDZ (throws) | TDZ (throws) |
| Reassign | Yes | Yes | No (the binding; objects inside can still change) |
| Redeclare in same scope | Yes, silently | No | No |
| Top level of a classic script | Becomes a window property | Global, not on window | Global, not on window |
In a for loop | One variable for the whole loop | A fresh binding per iteration | A fresh binding per iteration (for for…of) |
| Declared without a value | Yes, starts as undefined | Yes, starts as undefined | No: SyntaxError: Missing initializer in const declaration |
As a classic for counter (i++) | Works | Works | TypeError: Assignment to constant variable |
Inside switch cases | Shared by the whole function | Shared 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 2With 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 moduleThe 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
evalcan declare newvarvariables 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, soxmight meanobj.xor 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.
Aufgabe 1
`var` Inside a Function
var level = 'global';
function check() {
console.log(level);
var level = 'local';
}
check();Antwort zeigenAntwort ausblenden
undefined
- The
var levelinsidecheckis hoisted to the top of the function and shadows the global one. - Its assignment hasn’t run yet when
console.logexecutes, so it is stillundefined.
Aufgabe 2
The Same Thing With `let`
let mode = 'global';
function check() {
console.log(mode);
let mode = 'local';
}
check();Antwort zeigenAntwort ausblenden
ReferenceError: Cannot access 'mode' before initialization
- The inner
modeshadows the outer one from the start of the function. - At the
console.logline it is in its temporal dead zone.
Aufgabe 3
Which One Is Hoisted?
console.log(typeof declared, typeof assigned);
function declared() {}
var assigned = () => {};Antwort zeigenAntwort ausblenden
function undefined
- A function declaration is hoisted complete.
- For
var assigned, only the binding is hoisted, with the valueundefined; the arrow function is assigned when that line runs.
Aufgabe 4
Block Scope
if (true) {
var fromVar = 'var';
let fromLet = 'let';
}
console.log(fromVar, typeof fromLet);Antwort zeigenAntwort ausblenden
var undefined
varignores blocks and belongs to the surrounding function (or module), so it is visible after theif.fromLetbelongs to the block. Outside it the name doesn’t exist at all, sotypeofreturns'undefined'instead of throwing.
Aufgabe 5
Default Parameters
function size(width = height, height = 10) {
return width * height;
}
console.log(size(2));
console.log(size());Antwort zeigenAntwort ausblenden
20, then ReferenceError: Cannot access 'height' before initialization
- With an argument,
widthdoesn’t need its default. - Without one, the default for
widthruns whileheightis 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;
varignores blocks,let,constandclassrespect them. - Hoisting creates every binding when its scope starts: functions are ready,
varisundefined, andlet,constandclassare uninitialized. - The temporal dead zone is the time between a block starting and a
let,constorclassdeclaration running; using the name in it throws. - Use
constby default andletwhen you reassign; the TDZ turns “used too early” bugs into loud errors.
Sources
- ECMAScript specification: Annex B.3.2, block-level function declarations
- MDN: with
- MDN: Scope and Hoisting
- MDN: let: temporal dead zone
- MDN: var
- ECMAScript specification: Declarations and the variable statement
- ESLint: no-shadow
Tags
Häufige Fragen
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.