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.

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 var a new scope.
  • Initialization: its binding starts with undefined before statements run in that scope.
  • Redeclaration: another var with the same name can refer to the same binding. This does not permit a conflicting let or const declaration.
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 contextTop-level declarationsNew var name becomes a global-object property?
Browser classic script, including strict classic scriptGlobal bindingsYes
Browser moduleModule bindingsNo
Node CommonJS .cjs fileModule-local bindings inside the CommonJS wrapperNo
Node ES module .mjs fileModule bindingsNo

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.

DeclarationState before its statement is reachedWhat happens when execution reaches it?
var a = 5Initialized to undefinedAssigns 5
let b = 10UninitializedEvaluates the initializer before initializing b
const c = 15UninitializedEvaluates the initializer before initializing c
Function declaration in the examples belowInitialized to a callable functionNo later assignment is needed to make it callable
var fn = function () {}Initialized to undefinedCreates and assigns the function value
Class declarationUninitializedEvaluates 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

FileResultReason
APrints 2Redeclaration with var is allowed
BEarly SyntaxError, no outputDuplicate let in the same scope
CRuntime TypeError, no outputAttempts to reassign an initialized const
DEarly SyntaxError, no outputThis 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.

TheoryEasy

Reassignment vs Mutation with let and const

TheoryEasy

Temporal Dead Zone (TDZ)

TheoryMedium

Variable Options: var vs let vs const

TheoryMedium

Understanding Hoisting

TheoryMedium

Hoisting Output Prediction

Lesson completed?

Found a bug, typo, or have feedback?

Let me know