This code runs without an error:
console.log(count) // undefined
var count = 5
This code throws one:
console.log(total) // ReferenceError: Cannot access 'total' before initialization
let total = 5
Both lines read a variable before it is declared. The difference comes from hoisting and the temporal dead zone. This guide explains both, one declaration type at a time.
What Is Hoisting?
Before JavaScript runs a scope, it scans the code and registers every declaration in it. The declarations are known before the first line executes. People describe this as declarations being "moved to the top", but nothing moves. The engine creates the bindings first, then runs the code in order.
What differs between declaration types is what state the binding is in once it is created.
| Declaration | Created at scope start | Initial value | Usable before its line? |
|---|---|---|---|
var | Yes | undefined | Yes, but reads undefined |
function declaration | Yes | The function itself | Yes |
let | Yes | Uninitialized | No, throws |
const | Yes | Uninitialized | No, throws |
class | Yes | Uninitialized | No, throws |
Hoisting with var
A var binding exists from the start of its function, with the value undefined. Assignment happens only when execution reaches the line.
function demo() {
console.log(name) // undefined
var name = 'Ada'
console.log(name) // 'Ada'
}
The engine treats this as if the declaration were at the top and the assignment stayed where it was:
function demo() {
var name
console.log(name)
name = 'Ada'
console.log(name)
}
var is scoped to the function, not the block. A var inside an if or for is visible in the whole function, which is a source of bugs.
Hoisting with Functions
A function declaration is hoisted with its body, so you can call it before it appears in the file:
greet() // 'Hello'
function greet() {
console.log('Hello')
}
A function expression follows the rules of the variable that holds it:
sayHi() // TypeError: sayHi is not a function
var sayHi = function () {
console.log('Hi')
}
With var, sayHi exists but is undefined, so calling it is a TypeError. With let or const, the same call is a ReferenceError. Arrow functions assigned to a variable behave the same way.
What Is the Temporal Dead Zone?
let, const and class bindings are also created at the start of their scope. The difference is that they stay uninitialized until execution reaches their declaration. The period between the start of the scope and that line is the temporal dead zone, or TDZ. Touching the variable during that period throws a ReferenceError.
{
// TDZ for `price` starts here
console.log(price) // ReferenceError
const price = 20
// TDZ ends at the declaration
console.log(price) // 20
}
The word "temporal" matters. The zone is defined by time, meaning execution order, and not by position in the file. This function looks wrong and works:
const getTax = () => rate * 0.2
const rate = 100
console.log(getTax()) // 20
getTax mentions rate before its declaration in the file, but it is called after rate is initialized, so there is no error.
Proof That let Is Hoisted
If let were not hoisted, the inner declaration below would not matter and the first log would print 'outer':
const label = 'outer'
function show() {
console.log(label) // ReferenceError
const label = 'inner'
}
show()
The inner label is registered at the start of the function, so it shadows the outer one for the whole function body, and the early read lands in its TDZ. A binding that was not hoisted could not shadow anything.
The TDZ and typeof
typeof is usually safe on an undeclared name and returns 'undefined'. It is not safe inside a TDZ:
console.log(typeof missing) // 'undefined'
console.log(typeof early) // ReferenceError
let early = 1
Classes Are in the TDZ Too
const user = new User('Ada') // ReferenceError
class User {
constructor(name) {
this.name = name
}
}
Unlike function declarations, class declarations are not usable before their definition.
A Classic Bug: var in Loops
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// 3, 3, 3
There is one i for the whole function, and the timers run after the loop finishes. With let, each iteration gets its own binding:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// 0, 1, 2
Why the TDZ Exists
The TDZ turns a silent bug into a loud one. With var, reading a variable too early returns undefined and the program continues with bad data. With let and const, you get an error that names the variable and points at the line. It also makes const meaningful, since a constant that could be read before it had its value would not be constant.
Practical Rules
- Use
constby default,letwhen you reassign. Avoidvarin new code. - Declare variables at the top of the scope where you use them. This removes most TDZ errors by construction.
- Define classes and constants before the code that uses them at load time. Functions that use them later are fine.
- Prefer function declarations for helpers you want to call before their definition. Or place the helper above its first use.
- Turn on a linter rule such as
no-use-before-define. It catches the early reads.
Common Questions
Is let hoisted? Yes. The binding is created at the start of the scope, but it is uninitialized until its declaration runs.
Why does var return undefined and let throw? var bindings are initialized to undefined when created. let bindings are left uninitialized on purpose.
Does the TDZ affect function parameters? Yes, in one case. A default parameter that refers to a later parameter throws, as in function f(a = b, b) {}.
Summary
Every declaration is hoisted, and what changes is its initial state. var starts as undefined, function declarations start fully defined, and let, const and class start uninitialized until their line runs. The time in between is the temporal dead zone, and it exists to turn silent bugs into errors you can read.
