Variable Scope and Hoisting in JavaScript
Why does one read produce undefined while another throws before anything is printed? The difference is not just where a variable is declared. It also matters whether its binding has been initialized.
By the end, you should be able to identify a declaration's scope, predict which reads succeed and stop a trace at its first uncaught error. You'll also distinguish reassigning a binding from changing an object it refers to.
First pass: compare the declarations and read the hoisting and initialization sections. Then try Exercises 2 and 4 before their answers. Finish with the Hoisting Output Prediction practice question. You can stop when you can explain both the reachable output and why later statements do not run. The remaining examples cover redeclaration, mutation and function overwrites.
Every JavaScript fence is an independent file unless its context says otherwise. Use Node 24 CommonJS by saving it as example.cjs and running node example.cjs. The one browser example below explicitly needs a classic script on a fresh page. Error messages shown are Node 24 wording unless noted. A deliberately failing file is still useful to run on its own.
You only need basic calls, blocks and assignments here. This lesson explains when a binding becomes usable. Scope Chain follows the lookup to that binding. Closures uses the same rules when a function keeps access after an outer call returns. The execution-context terminology is optional background in Context and Call Stack.
Variable Declarations: var, let, and const
var Declaration
- Scope: the enclosing function, or the top-level script/module context if there is no enclosing function. An ordinary block does not give
vara new scope. - Initialization: its binding starts with
undefinedbefore statements run in that scope. - Redeclaration: another
varwith the same name can refer to the same binding. This does not permit a conflictingletorconstdeclaration.
function example() {
var x = 10
if (true) {
var x = 20
console.log(x)
}
console.log(x)
}
example()
This logs 20 twice. The declaration in the if block does not create a second x. Its assignment changes the function's existing binding.
let Declaration
- Scope: the enclosing block, including function bodies and loop bodies.
- Initialization: the binding exists before the declaration runs but cannot be read until initialized. That interval is its Temporal Dead Zone, or TDZ.
- Redeclaration: a duplicate lexical declaration in the same scope is a
SyntaxError. Reassignment after initialization is allowed.
function example() {
let x = 10
if (true) {
let x = 20
console.log(x)
}
console.log(x)
}
example()
This logs 20 then 10. The block's x and the function body's x are separate bindings.
const Declaration
const has block scope and a TDZ like let. An ordinary const declaration needs an initializer. After initialization, the binding cannot be reassigned.
This file intentionally throws a TypeError on its second line and prints nothing:
const x = 10
x = 20
That restriction does not freeze an object. In this separate file, the property mutation succeeds before the binding reassignment throws:
const person = { name: 'Alice' }
person.name = 'Bob'
console.log(person.name)
person = { name: 'Charlie' }
The output is Bob, followed by a TypeError. person.name = 'Bob' changes the existing object. person = ... tries to replace the value in the binding.
Variable Scope
Global Scope
Top-level does not mean the same thing in every host. In a browser classic script, top-level var declarations normally create properties on the global object. Top-level let, const and class declarations create global lexical bindings, not window properties. A later classic script on the same page can read those bindings by name.
| File context | Top-level declarations | New var name becomes a global-object property? |
|---|---|---|
| Browser classic script, including strict classic script | Global bindings | Yes |
| Browser module | Module bindings | No |
Node CommonJS .cjs file | Module-local bindings inside the CommonJS wrapper | No |
Node ES module .mjs file | Module bindings | No |
For this example only, use the contents of a browser classic script element on a fresh page, not a module or a developer-console REPL. The names must not already exist on that page.
var scopeDemoVar = 'global property'
let scopeDemoLet = 'global lexical binding'
console.log(window.scopeDemoVar)
console.log(Object.hasOwn(window, 'scopeDemoLet'))
console.log(scopeDemoLet)
It logs global property, false and global lexical binding on separate lines. Adding 'use strict' at the start of a classic script does not make its top-level var module-local.
In the default Node CommonJS context, this next example logs I am outside the function. globalVar is visible to the nested function but is module-local, despite its name:
var globalVar = 'I am outside the function'
function showGlobal() {
console.log(globalVar)
}
showGlobal()
Function Scope
A var declared inside a function is not available to surrounding code.
function myFunction() {
var functionVar = 'I am inside a function'
console.log(functionVar)
}
myFunction()
console.log(functionVar)
This logs I am inside a function then throws ReferenceError: functionVar is not defined. The outer code cannot find that local binding.
Block Scope
let and const stay within their enclosing block. A class declaration is block-scoped too.
if (true) {
let blockVar = 'I am inside a block'
console.log(blockVar)
}
console.log(blockVar)
This logs I am inside a block then throws ReferenceError: blockVar is not defined. Compare that missing binding with a binding that exists but is still uninitialized below.
Hoisting
What is Hoisting?
Hoisting is a name for declaration behavior visible before execution reaches the declaration's source position. JavaScript does not literally move your source lines. A more useful trace asks what bindings have been set up and what values, if any, they hold.
| Declaration | State before its statement is reached | What happens when execution reaches it? |
|---|---|---|
var a = 5 | Initialized to undefined | Assigns 5 |
let b = 10 | Uninitialized | Evaluates the initializer before initializing b |
const c = 15 | Uninitialized | Evaluates the initializer before initializing c |
| Function declaration in the examples below | Initialized to a callable function | No later assignment is needed to make it callable |
var fn = function () {} | Initialized to undefined | Creates and assigns the function value |
| Class declaration | Uninitialized | Evaluates the class before initializing its outer binding |
Hoisting with var
console.log(a)
var a = 5
console.log(a)
The output is undefined then 5. The binding is already initialized at the first read. The initializer's assignment has not happened yet.
Hoisting with let and const
console.log(b)
let b = 10
This file prints nothing. Reading b throws a ReferenceError because the binding exists but is uninitialized. The initializer never runs after this uncaught failure.
Function Hoisting
greet()
function greet() {
console.log('Hello!')
}
This logs Hello!. The function declaration has already supplied a callable value when greet() runs.
Function Expressions and Hoisting
The variable's declaration kind controls availability. Creating the function expression still requires evaluating that expression.
sayHi()
var sayHi = function () {
console.log('Hi!')
}
This prints nothing and throws a TypeError: sayHi currently holds undefined, which is not callable. With let or const instead of var, this same early call would fail with a TDZ ReferenceError.
Binding Initialization and the TDZ
Understanding TDZ
The TDZ lasts from entering the relevant scope until the binding is initialized. It affects let, const and class declarations. Merely reaching an initializer's line is not enough if that initializer tries to read the binding it is still initializing.
First, a read before the declaration. This intentionally throws a ReferenceError without output:
{
console.log(x)
let x = 5
}
Next, a separate self-reference. It also throws a ReferenceError without output:
let value = value
The right-hand value must be read before the left-hand binding can be initialized. With let value and no initializer, initialization instead gives it undefined when that declaration executes.
A class name has the same early-read restriction. No class syntax beyond its name is needed for this comparison:
console.log(Example)
class Example {}
This throws a ReferenceError. It does not print a class value.
typeof Does Not Bypass Initialization
Run this as a fresh file where missingScopeDemo is not declared:
console.log(typeof missingScopeDemo)
console.log(typeof waiting)
let waiting = 1
Only undefined is printed. typeof permits an unresolved name, but the read of the existing, uninitialized waiting binding throws. This distinction is the key to the Hoisting Output Prediction practice question.
Redeclaration and Reassignment
var
var a = 1
var a = 2
a = 3
console.log(a)
This logs 3. Redeclaring the same var is allowed here. Both later assignments update the same binding.
let
Reassignment is allowed:
let b = 1
b = 3
console.log(b)
This logs 3. In contrast, the following independent file has an early SyntaxError:
console.log('This never runs')
let b = 1
let b = 2
No statement runs, including the log before the duplicate declaration. This is not a runtime trace that continues until the second declaration.
const
These are separate failing files, not consecutive operations. Reassignment is a runtime error:
const c = 1
c = 2
It throws a TypeError. Redeclaration is an early error:
const c = 1
const c = 3
That entire file is rejected with a SyntaxError before execution.
Best Practices
Prefer const and let over var
Use const when you do not intend to replace the binding's value. Use let when reassignment is part of the design. Knowing var still matters when reading existing code or explaining a trace.
Minimize Global Variables
Keep state in the function or module that owns it. Global names make it harder to see which code can change a value. Do not assume that moving a declaration to the top of a file makes it a browser global.
Understand Scope
Use a block when a temporary binding should not be accessible afterward. Reusing an outer name creates shadowing, not a temporary update to the outer binding.
Avoid Hoisting Confusion
Declare a binding before its first use, close to the work that needs it. Prefer clear execution order over relying on an early undefined. When tracing failures, distinguish a file rejected before execution from an uncaught exception during execution.
Immutable Data
const is not an immutability policy. Choose mutation or copying deliberately. Object.assign mutates its target. Use a fresh target when you want to leave the source object's own properties alone:
const original = { count: 1, settings: { visible: true } }
const copy = Object.assign({}, original, { count: 2 })
console.log(original.count, copy.count)
console.log(original.settings === copy.settings)
This logs 1 2 then true. The copy is shallow. Its settings still refers to the same nested object. Object spread has the same shallow-copy limitation.
Exercises
Try each question before reading its answer. Keep separate files separate, especially when a comparison contains an early error.
Exercise 1: Scoping with var, let and const
What will this log? Identify which declarations share a binding.
function testScope() {
var x = 1
let y = 2
const z = 3
{
var x = 100
let y = 200
const z = 300
console.log('Inside block:', x, y, z)
}
console.log('Outside block:', x, y, z)
}
testScope()
Answer
Inside block: 100 200 300
Outside block: 100 2 3
The block's var x uses the function's existing x. Assigning 100 changes what the later log reads. The inner y and z are distinct block bindings. Their assignments do not change the outer 2 and 3.
Exercise 2: Hoisting Behavior
Predict every printed line. Does the failure reading b stop the whole file?
console.log(a)
var a = 10
try {
console.log(b)
} catch (err) {
console.log(err.message)
}
let b = 20
function foo() {
console.log(c)
var c = 30
}
foo()
Answer
In Node 24:
undefined
Cannot access 'b' before initialization
undefined
a starts initialized to undefined. The read of b fails, but the catch logs the error message and execution continues after the try/catch. b is then initialized to 20. Calling foo() creates its local c binding initialized to undefined before the first statement in the function runs.
Without that try/catch, the uncaught error would stop this synchronous trace. It would not skip just the failed line and carry on to foo().
Exercise 3: Redeclaration and Reassignment
Treat A, B, C and D as four independent files. Which succeed, which fail before execution and which fail during execution?
A
var x = 1
var x = 2
console.log(x)
B
let y = 1
let y = 2
C
const z = 1
z = 2
D
const w
Answer
| File | Result | Reason |
|---|---|---|
| A | Prints 2 | Redeclaration with var is allowed |
| B | Early SyntaxError, no output | Duplicate let in the same scope |
| C | Runtime TypeError, no output | Attempts to reassign an initialized const |
| D | Early SyntaxError, no output | This const declaration has no initializer |
Combining these into one file would not demonstrate all four outcomes in sequence. An early syntax error rejects the whole file before the reassignment can run.
Exercise 4: Temporal Dead Zone
Which line is reached? What changes if Line A is removed?
{
console.log(a) // Line A
let a = 5
console.log(a) // Line B
}
Answer
Line A throws a ReferenceError without output. The declaration and Line B are never reached. In the separate variation with Line A removed, initialization completes and Line B prints 5. That is a changed program, not a second result of the original trace.
Exercise 5: Function Hoisting
What does each call use?
foo()
var foo = function () {
console.log('foo')
}
function foo() {
console.log('FOO')
}
foo()
Answer
FOO
foo
In this CommonJS file, declaration setup initializes foo to the function declaration. The var foo declaration does not reset that value to undefined. The first call therefore logs FOO.
Execution then evaluates the function expression and assigns it to foo. The second call uses that new value and logs foo. The intervening function declaration does not overwrite the assignment again when execution passes its source position.
If you can name the binding, state whether it is initialized and stop at the right failure, you have the model needed for outward name lookup. You do not need to memorize a fictional rearrangement of the source.
Practice Problems
Completion marks record your own progress, not an automatically checked result. The task type does not determine whether it is optional.