Explain the concept of "hoisting" in JavaScript
TL;DR
Hoisting is a JavaScript mechanism where variable and function declarations are moved ("hoisted") to the top of their containing scope during the compile phase.
- Variable declarations (
var): Declarations are hoisted, but not initializations. The value of the variable isundefinedif accessed before initialization. - Variable declarations (
letandconst): Declarations are hoisted, but not initialized. Accessing them results inReferenceErroruntil the actual declaration is encountered. - Function expressions (
var): Declarations are hoisted, but not initializations. The value of the variable isundefinedif accessed before initialization. - Function declarations (
function): Both declaration and definition are fully hoisted. - Class declarations (
class): Declarations are hoisted, but not initialized. Accessing them results inReferenceErroruntil the actual declaration is encountered. - Import declarations (
import): Declarations are hoisted, and side effects of importing the module are executed before the rest of the code.
The following behavior summarizes the result of accessing the variables before they are declared.
| Declaration | Accessing before declaration |
|---|---|
var foo | undefined |
let foo | ReferenceError |
const foo | ReferenceError |
class Foo | ReferenceError |
var foo = function() { ... } | undefined |
function foo() { ... } | Normal |
import | Normal |
Hoisting
Hoisting is a term used to explain the behavior of declarations in JavaScript code.
Variables declared with the var keyword have their declaration "moved" up to the top of their containing scope during compilation, which we refer to as hoisting.
Only the declaration is hoisted; the initialization/assignment (if there is one) will stay where it is. Note that the declaration is not actually moved – the JavaScript engine parses the declarations during compilation and becomes aware of variables and their scopes, but it is easier to understand this behavior by visualizing the declarations as being "hoisted" to the top of their scope.
Let's explain with a few code samples. Note that the code for these examples should be executed within a module scope instead of being entered line by line into a REPL like the browser console.
Hoisting of variables declared using var
Hoisting is visible here: even though foo is declared and initialized after the first console.log(), the first console.log() prints undefined.
console.log(foo); // undefinedvar foo = 1;console.log(foo); // 1
You can visualize the code as:
var foo;console.log(foo); // undefinedfoo = 1;console.log(foo); // 1
Hoisting of variables declared using let, const, and class
Variables declared via let, const, and class are hoisted as well. However, unlike var and function, they are not initialized and accessing them before the declaration will result in a ReferenceError exception. The variable is in a "temporal dead zone" from the start of the block until the declaration is processed.
y; // ReferenceError: Cannot access 'y' before initializationlet y = 'local';
z; // ReferenceError: Cannot access 'z' before initializationconst z = 'local';
Foo; // ReferenceError: Cannot access 'Foo' before initializationclass Foo {constructor() {}}
Hoisting of function expressions
A function expression is a function assigned to a variable binding. When the binding uses var, only the variable declaration is hoisted — the function body is not.
console.log(bar); // undefinedbar(); // Uncaught TypeError: bar is not a functionvar bar = function () {console.log('BARRRR');};
Arrow functions are function expressions too, so the same rule applies — only the binding is hoisted, and its TDZ behavior follows the declaration keyword (var initializes to undefined, let and const remain in the TDZ until their declaration runs).
console.log(baz); // undefinedvar baz = () => 'arrow';console.log(baz()); // 'arrow'
Hoisting of function declarations
Function declarations use the function keyword. Unlike function expressions, function declarations have both the declaration and definition hoisted, thus they can be called even before they are declared.
console.log(foo); // [Function: foo]foo(); // 'FOOOOO'function foo() {console.log('FOOOOO');}
The same applies to generator functions (function*), async functions (async function), and async generator functions (async function*).
Hoisting of import statements
Import declarations are hoisted. The identifiers the imports introduce are available in the entire module scope, and their side effects are produced before the rest of the module's code runs.
foo.doSomething(); // Works normally.import foo from './modules/foo';
Under the hood
In reality, JavaScript creates all variables in the current scope before it even tries to execute the code. Variables created using the var keyword will have the value of undefined, whereas variables created using the let and const keywords will be marked as <value unavailable>. Thus, accessing them will cause a ReferenceError, preventing you from accessing them before initialization.
In the ECMAScript specification, let and const declarations are explained as below:
The variables are created when their containing Environment Record is instantiated but may not be accessed in any way until the variable's LexicalBinding is evaluated.
However, this statement is a little different for the var keyword:
Var variables are created when their containing Environment Record is instantiated and are initialized to
undefinedwhen created.
MDN groups hoisting into four observable behaviors, which map to the declaration kinds covered above:
- Value hoisting — the value is usable before the declaration. Applies to function declarations.
- Declaration hoisting — the binding is usable before the declaration but reads
undefined. Applies tovar. - Scope tainting — the binding exists from the top of the scope but any access throws (the TDZ). Applies to
let,const, andclass. - Side effects — the declaration's side effects run before the rest of the module evaluates. Applies to
import.
Modern practices
In practice, modern codebases avoid using var and use let and const exclusively. It is recommended to declare and initialize your variables and import statements at the top of the containing scope/module to eliminate the mental overhead of tracking when a variable can be used.
ESLint is a static code analyzer that can find violations of such cases with the following rules:
no-use-before-define: Warns when an identifier is referenced before its declaration appears in source.no-undef: Warns when an identifier is referenced without being declared anywhere in scope.
Additional examples
The examples below cover hoisting behaviors that are less obvious from the summary table and that commonly cause confusion.
Function declaration compared with function expression
console.log(declared());console.log(expressed());function declared() {return 'function declaration';}var expressed = function () {return 'function expression';};
declared()returns'function declaration'. Function declarations are fully hoisted — both the identifier binding and the function body are available from the top of the scope.expressed()throwsTypeError: expressed is not a function. Thevar expressedbinding is hoisted and initialized toundefined, but the assignment of the function expression happens at its source location. Callingundefined()produces theTypeError.
var in a for loop with setTimeout
for (var i = 0; i < 3; i++) {setTimeout(() => console.log(i), 0);}// Output: 3, 3, 3
var i is function-scoped rather than block-scoped, so all three callbacks close over the same binding. The loop increments i to 3 before any setTimeout callback runs, because macrotasks run after the current synchronous code completes. Each callback then reads the current value of the shared i, which is 3.
Two fixes:
- Replace
varwithlet.letis block-scoped, so each iteration creates a fresh binding that the callback closes over. - Wrap the body in an IIFE that captures the current value as a parameter:
(i => setTimeout(() => console.log(i), 0))(i). This was the pre-ES6 workaround.
var escapes block scope
if (true) {var a = 1;let b = 2;}console.log(a); // 1console.log(b); // ReferenceError: b is not defined
var is scoped to the nearest function or script, not to the enclosing block. The declaration is hoisted past if, for, while, and plain block statements to the containing function or module scope, which is why a is still visible after the if. let and const are block-scoped, so b only exists inside the block.
Redeclaration
var x = 1;var x = 2; // OK — x is now 2let y = 1;let y = 2; // SyntaxError: Identifier 'y' has already been declared
var allows the same name to be redeclared in the same scope; the second declaration is a no-op and only the assignment runs. let, const, and class throw SyntaxError if the same name is declared twice in the same scope. Like hoisting, this is resolved statically before execution — duplicate lexical declarations are an early error detected during parsing, so no code runs at all.
Class declarations
console.log(typeof Foo);class Foo {}
This throws ReferenceError: Cannot access 'Foo' before initialization.
Class declarations are hoisted — the binding is created at the top of the enclosing block — but they remain in the Temporal Dead Zone until the class declaration is evaluated. Any access before that point throws, including typeof.
This behavior can be confused with "classes are not hoisted". The two statements are observably different:
- If
Foowere not hoisted,typeof Foowould return'undefined'(the behavior for truly undeclared identifiers). - Because
Foois hoisted but uninitialized,typeof Foothrows.
The distinction also matters for extends clauses, which are evaluated at class declaration time. class A extends B {} throws if B is hoisted but still in the TDZ at that point.
typeof and the Temporal Dead Zone
console.log(typeof undeclaredVariable); // 'undefined'console.log(typeof someLet); // ReferenceErrorlet someLet = 1;
typeof does not throw when applied to an identifier that has no declaration anywhere in scope — it returns the string 'undefined'. However, typeof does throw when applied to an identifier that is declared but still in the Temporal Dead Zone. The binding exists, and reading it (which typeof must do) triggers the TDZ error.
This distinguishes "undeclared" (no binding in any enclosing scope) from "declared but uninitialized" (binding exists, initialization has not yet occurred).
Shared names across var and function declarations
function outer() {console.log(inner);inner();function inner() {console.log('inner called');}var inner = 'overwritten';}outer();// Output:// [Function: inner]// inner called
Two behaviors combine here:
- Both
var innerand thefunction innerdeclaration are hoisted to the top ofouter. - When a
vardeclaration and a function declaration share a name in the same scope, the function declaration takes precedence during initialization.inneris initialized with the function object rather thanundefined.
The var inner = 'overwritten' assignment takes effect only after the two console.log calls, so those calls observe the function. A console.log(inner) after the assignment would print 'overwritten'.
A let or const declaration in the same scope as a var of the same name produces a SyntaxError at parse time, before any code runs.
Common misconceptions
The following statements appear frequently in explanations of hoisting, including in material generated by large language models, but are incorrect or imprecise:
- Classes are not hoisted. Class declarations are hoisted. They remain in the Temporal Dead Zone until the declaration is evaluated, which is observably different from not being hoisted at all — most notably,
typeofthrows on a class in the TDZ but returns'undefined'for a truly undeclared identifier. varis hoisted;letandconstare not. All three are hoisted. They differ in initialization:varis initialized toundefinedat hoist time, whileletandconstremain uninitialized in the TDZ until their declaration is evaluated.typeofnever throws on undeclared variables.typeofis safe for identifiers that have no declaration anywhere in scope, but it throws in the TDZ. Thetypeof x === 'undefined'guard is only safe ifxis not declared anywhere in the enclosing scope.- Function declarations are hoisted; function expressions are not. Both are hoisted, but only the binding. In
var fn = function () {}, thevar fndeclaration is hoisted and initialized toundefined. Inlet fn = function () {}, the binding is hoisted but remains in the TDZ. The function body is never hoisted for expressions.
