๐Ÿณ IntuitiveFE
Login
โ† All concepts

Hoisting

โฑ๏ธ ~3-minute bite ยท solve the sandbox to master

0%lesson
๐Ÿง’

5-Year-Old Metaphor

โ€” The physical, real-world picture. No jargon.

๐Ÿ“‹ The stage manager's pre-show checklist โ€” everything is catalogued before the curtain rises.

The real-world analogy

Before a theatre show opens, the stage manager walks every inch of the stage and writes a property list: which props exist, which are loaded and ready, which are still in the truck. When the show starts, the actors know exactly what they can pick up โ€” and what will explode in their hands if they try too early.

JavaScript's engine does the same thing before it runs your code. It scans every scope, registers every declaration, and decides what's "ready" vs "still in the truck."

The actual mechanism โ€” not "code moving"

Hoisting is not the engine physically moving your declarations to the top of the file. That's the simplified mental model most tutorials use. What really happens:

  1. Parse phase: Engine builds the Abstract Syntax Tree (AST) from your source code.
  2. Binding instantiation: Engine walks the AST and creates memory slots (bindings) in Environment Records for every declaration it finds. This happens before any code runs.
  3. Initialization policy differs by keyword: var and function bindings are initialized immediately (undefined and full value, respectively). let, const, and class bindings are left uninitialized โ€” in the Temporal Dead Zone.
  4. Execution phase: Code runs line-by-line. Bindings transition from uninitialized โ†’ initialized as their declaration lines are hit.

Three hoisting tiers

Tier 1

Full value hoisting โ€” function declarations, import

Binding exists AND is fully initialized before any code runs. You can use the function above where it's written.

Tier 2

Declaration hoisting โ€” var

Binding exists and is initialized to undefined. Using it before the assignment returns undefined silently โ€” the source of many bugs.

Tier 3

TDZ hoisting โ€” let, const, class

Binding exists in the Environment Record but is marked uninitialized. Accessing it before the declaration line throws ReferenceError. A feature, not a bug โ€” it catches Tier 2 problems at development time.

The TDZ proof โ€” why let/const IS hoisted

Sceptical that let is "hoisted" if accessing it throws? This proves it:

js
1const x = 1; // outer x
2{
3 console.log(x); // ReferenceError โ€” NOT 1!
4 const x = 2; // inner x
5}

If the inner const x were NOT hoisted, the engine would look up the outer scope and print 1. Instead it throws โ€” because the inner binding was already registered in the block's Environment Record during binding instantiation, shadowing the outer one and placing it in TDZ. That's proof of hoisting.

๐ŸŽ›๏ธ

Interactive Sandbox

โ€” Move something, see it react instantly.

Declaration type

// Simplified scope:
console.log(x); // โ† "before" phase access here
var x = 42; // โ† the declaration
console.log(x); // โ† "after" phase access here

Execution phase

Environment Record โ€” binding state

js
1x โ†’ undefined โ† hoisted, initialized to undefined

console.log(x) returnsโ€ฆ

Nothing runs yet โ€” JS is scanning declarations and building the Environment Record.

Key: Hoisted + initialized to undefined. Accessing before decl returns undefined silently.
Explored at "Before decl":varletconstfunction declfunction exprclass
๐ŸŽฏ

Challenge

Explore all 6 declaration types at the 'Before decl' phase. See what each one returns โ€” and why.

Try it
๐ŸŽฏ

Why Should I Care?

โ€” The exact interview question + the bug it kills.

Exact interview questions

Q1: "What does this log, and why?"

js
1console.log(a); // ?
2var a = 10;
3console.log(a); // ?

Answer: undefined, then 10. var a is hoisted and initialized to undefined before execution starts. The assignment = 10 runs at the declaration line.

Q2: "Why does this loop print 3, 3, 3?"

js
1for (var i = 0; i < 3; i++) {
2 setTimeout(() => console.log(i), 0);
3}
4// Logs: 3 3 3

Answer: var i is function-scoped โ€” all three callbacks close over the same binding. By the time the callbacks fire (after the synchronous loop completes), i is already 3. Fix: use let i (each iteration creates a new block-scoped binding) or an IIFE.

Q3: "What does typeof return for a TDZ variable?"

js
1console.log(typeof undeclared); // "undefined" โ€” safe
2console.log(typeof tdz); // ReferenceError!
3let tdz = 1;

Answer: typeof on a truly undeclared variable returns "undefined" safely. But if the variable is declared with let/constand in TDZ, typeof still throws โ€” TDZ is stricter than undeclared.

Production bugs hoisting causes

Bug: silent undefined for non-premium users

js
1function checkout(user) {
2 if (user.premium) {
3 var code = "SAVE20";
4 }
5 // 200 lines later...
6 applyCode(code); // undefined for non-premium โ€” no error!
7}

var code is hoisted to the function scope as undefined. Non-premium users silently get applyCode(undefined). With let, this would throw ReferenceError and get caught in testing.

Bug: calling a function expression before assignment

js
1initApp(); // TypeError: initApp is not a function
2ย 
3var initApp = function() { /* setup */ };

The developer expects function-declaration behaviour but wrote a function expression.var initApp is hoisted as undefined โ€” calling undefined()throws TypeError. Switch to a function declaration or move the call after the expression.

Complexity / at-a-glance table

KeywordHoistedInit valueTDZScope
varโœ“undefinedโœ—function
letโœ“โŸจTDZโŸฉโœ“block
constโœ“โŸจTDZโŸฉโœ“block
functionโœ“full valueโœ—function
fn exprโœ—โ€”โ€”depends on keyword
classโœ“โŸจTDZโŸฉโœ“block
importโœ“full valueโœ—module
๐Ÿ”ฌ

The Deep Dive

โ€” Spec refs, engine internals, the minutiae.

ECMAScript spec: LexicalEnvironment vs VariableEnvironment

Every Execution Context (function call, module, global code) has two environment slots:

  • VariableEnvironment โ€” holds var bindings and function declarations. Created for the entire function/global scope regardless of blocks.
  • LexicalEnvironment โ€” holds let, const, classbindings. Each block gets its own nested Lexical Environment.

When code enters a block (if, for, {}), the engine creates a new Lexical Environment chained to the outer one. var declarations walk up to the enclosing VariableEnvironment (the function or global), ignoring blocks entirely โ€” this is why var"escapes" if statements and loops.

TDZ in detail: what "uninitialized" means in the spec

The spec (ECMA-262 ยง9.1) defines binding state as one of: uninitialized, or a value. When a binding is uninitialized, any Get or Set operation on it calls ThrowReferenceError. This is the TDZ.

For let/const/class, bindings are created (instantiated) during the "Declaration Instantiation" algorithm that runs before execution, but their initializationonly happens when the declaration statement is reached in execution order.

js
1// This proves TDZ is a BINDING issue, not a scope issue:
2let x = "outer";
3{
4 // Inner block's Environment Record is created
5 // x โ†’ uninitialized โ† already registered, shadows outer x
6 // Accessing x here reads from inner ER โ†’ throws
7 typeof x; // ReferenceError (not "string"!)
8 let x = "inner";
9 // Now x โ†’ "inner"
10}

Block-level function declarations โ€” the dangerous edge case

Function declarations inside blocks (if, loops) have historically inconsistent behaviour across engines. In strict mode, block-level function declarations are block-scoped (like let). In non-strict ("sloppy") mode, the spec introduced legacy compatibility semantics that vary by engine.

js
1"use strict";
2{
3 foo(); // Works โ€” block-scoped function declaration
4 function foo() {}
5}
6console.log(typeof foo); // "undefined" โ€” not visible outside block
7ย 
8// Non-strict: AVOID โ€” result varies by engine
9if (true) {
10 function bar() {}
11}
12console.log(typeof bar); // "function" in V8, may differ elsewhere

Rule: never write function declarations inside if/for/while blocks. Use function expressions assigned to const instead.

import hoisting โ€” the strongest form

ES module import statements are fully hoisted with values before any module code runs. More precisely: the entire module graph is evaluated (all imports resolved, all side effects executed) before the importing module's own code starts. This is stronger than function-declaration hoisting.

js
1// Even this works:
2console.log(add(1, 2)); // 3 โ€” import is fully live
3import { add } from "./math.js";
4ย 
5// Module evaluation order: ./math.js โ†’ this file โ†’ consumers
6// Each module evaluates exactly once (cached after first evaluation)

Common implementation bugs

  • var in try/catch: the catch parameter (e) is scoped to the catch block, but any var declared inside the catch escapes โ€” including accidental redeclarations of outer variables.
  • Redeclaring var after function: function a() {} followed by var a = 1; โ€” the function is hoisted first, then the var initializer overwrites it at runtime. The reverse order (var then function) leaves the function value intact if there's no var initializer.
  • typeof safety net breaks with TDZ: developers sometimes use typeof x !== "undefined" as a safe feature-detect guard. This is broken if x is declared with let/const later in the same scope โ€” TDZ makes typeof throw too.
  • eval() and hoisting: direct eval() can inject var bindings into the surrounding scope. Indirect eval (via a variable reference) runs in global scope. Both are footguns โ€” avoid entirely.

Linting and prevention

  • ESLint: no-var โ€” enforce let/const everywhere
  • ESLint: no-use-before-define โ€” catches usage before declaration even when it's technically legal
  • ESLint: no-redeclare โ€” prevents var redeclarations
  • TypeScript strict mode โ€” type flow analysis catches uninitialized usage patterns
  • Declare at top of scope โ€” making your own declarations visible matches how the engine sees them
๐ŸŽค

Interview Questions

โ€” Real questions from real interviews โ€” with answers.

`undefined` โ€” var is hoisted and initialized to undefined before execution.

Yes โ€” it's hoisted into TDZ (Temporal Dead Zone), not to `undefined`.

TDZ is the period from a scope's start to a `let`/`const` declaration where accessing the binding throws.

Function declarations are fully hoisted with their value; function expressions only hoist the variable (as `undefined`).

Class semantics intentionally require the definition to be evaluated before use โ€” unlike function declarations.

In strict mode, block-level function declarations are block-scoped; in sloppy mode, behavior is engine-dependent and legacy-compat.

๐ŸŽฎ

Memory Game

โ€” Quick quiz โ€” lock the concept in long-term memory.
1/4

What is a 'parameter default value' TDZ interaction in JavaScript?