JavaScript Hoisting: What Actually Moves, and What Does Not
Hoisting is often taught as 'declarations move to the top,' but the real story is about when bindings are created and when you are allowed to read them. This post separates `var`, `let`, `const`, functions, and classes — and ties each to the temporal dead zone.
The usual one-sentence explanation
Hoisting is the name we give to JavaScript’s behavior of processing declarations before it runs the rest of the code in a scope. It does not mean the engine physically moves lines in your source file. It means the scope is prepared so that certain identifiers exist (and, for var, start as undefined) before execution reaches the lines where you wrote the declaration.
To reason about bugs and interview questions, you need two ideas:
- When is the binding created?
- When are you allowed to read or call it — and what value do you see?
The answers differ for var, for let and const, for function declarations, and for class declarations.
var: hoisted and initialized with undefined
A var in a function body or at the top level of a script is hoisted to the top of its function scope (or global/script scope), not to the innermost block.
The binding exists for the whole scope, and it is initialized with undefined at the start of that scope’s execution. That is why this runs without throwing:
console.log(x); // undefined (not ReferenceError)
var x = 1;
console.log(x); // 1
Only the declaration is hoisted, not the assignment. The assignment x = 1 stays where you wrote it. Conceptually, the engine treats the snippet like:
var x; // hoisted; x is undefined here
console.log(x);
x = 1;
console.log(x);
Block caveat: var ignores block scope (for the most part). A var inside an if or for still belongs to the enclosing function or script:
if (true) {
var a = 2;
}
console.log(a); // 2 — still in outer function/script scope
If you expect “block-local” behavior, use let or const instead.
function declarations: hoisted with the full function value
A function declaration — function name() { ... } — is hoisted in a stronger way: the identifier is bound to the function object for the entire scope, so you can call it before its textual position:
console.log(f()); // works: returns 1
function f() { return 1; }
That is different from a function expression assigned to var, where only the var binding is hoisted (as undefined), not the function:
console.log(g); // undefined
var g = function () { return 1; };
console.log(g()); // 1
Named function expressions and arrow functions assigned to let/const follow the rules of their binding (let/const), not the function-declaration rule.
let and const: hoisted, but in the temporal dead zone
let and const are hoisted in the sense that the engine knows about the binding for the whole block — you cannot declare the same name twice in the same block, and the binding is not visible in outer scopes. But they are not initialized with undefined like var.
From the start of the block until the line where the declaration is executed, the identifier is in the temporal dead zone (TDZ). Reading it throws ReferenceError:
// console.log(y); // ReferenceError if uncommented
let y = 2;
console.log(y); // 2
const is the same idea, plus you cannot reassign the binding.
The TDZ exists so that let/const behave more like “real” block-scoped variables: you do not get silent undefined reads for code that runs before the declaration in the source order.
class declarations
class declarations are not hoisted like function declarations. Like let/const, they are in the TDZ from the start of the block until the class line runs:
// new C(); // ReferenceError
class C {}
If you need a hoisted callable, use a function declaration or assign a class expression to var (with the usual var caveats).
Order of declarations in one scope (informal)
When several hoisted declarations compete in the same scope, the spec defines precise evaluation order. In practice, remember:
varbindings are created first withundefined.functiondeclarations’ initializations are applied (so names point at functions).- Then code runs top to bottom, including
let/const/classinitializers and assignments.
Edge cases (multiple var and function with the same name) are defined by the language spec; in strict modern code, avoid reusing the same identifier for both var and function in one scope — it is confusing and easy to get wrong.
import (modules)
In ES modules, import declarations are hoisted: the module graph is linked and imports are bound before module body code runs. You do not get TDZ surprises for imports the same way as for let, but cyclic dependencies can still produce surprising initialization order; that is a separate topic from classic hoisting.
Mental model for debugging
- Draw the scope (function, block, module).
- For each identifier, ask:
var(undefined until assigned)?let/const(TDZ until the line)? function declaration (callable immediately)? - Remember
vardoes not respect block scope the waylet/constdo.
Takeaways
- Hoisting is about when bindings enter the scope and what they contain before your code runs, not about physically moving lines.
var: hoisted, initialized asundefined, function-scoped.let/const/class: hoisted in the block, but TDZ until the declaration runs — no silentundefined.functiondeclarations: hoisted with the function value; expressions follow their binding (varvslet).- Prefer
letandconstin modern code so scope and TDZ match how people read the source.
This is an explanatory overview, not a substitute for the ECMAScript specification when you are designing tooling or polyfills.
Rate this post
All fields are optional. Just stars is fine.