JavaScript

Hoisting & TDZ

15 Questions

var declarations are hoisted to the top of their enclosing function scope and initialized to undefined before any code runs, meaning you can reference a var variable before its written declaration without a ReferenceError. Unlike let and const, var ignores block boundaries entirely — variables inside if, for, and while blocks escape to the enclosing function. Only the declaration is hoisted, never the assignment.
console.log(x); // undefined — hoisted as undefined, not ReferenceError
var x = 5;
console.log(x); // 5

// var leaks out of block scope
if (true) {
  var leaky = 'I escaped';
}
console.log(leaky); // 'I escaped' — visible outside block

// for-loop var leaks out
for (var i = 0; i < 3; i++) {}
console.log(i); // 3 — i accessible after loop ends

// Engine hoisting (mental model):
// var x; var leaky; var i; (all initialized to undefined)
// ...then code executes top-to-bottom with assignments

Why it matters: Output prediction questions about var hoisting appear in nearly every senior JavaScript interview; knowing that hoisted var is undefined (not a ReferenceError) versus let/const TDZ behavior is an essential distinction.

Real applications: Legacy React class components and early jQuery plugins relied on var, causing callbacks to capture unexpected undefined values or post-loop final values because var hoisting ignores block boundaries.

Common mistakes: Assuming console.log(x); var x = 5; throws a ReferenceError — it logs undefined because var is hoisted as undefined, unlike let/const which throw inside the TDZ.

Function declarations are completely hoisted — both the name and the entire body are available at the top of the scope before any code executes, so you can call them before their written position. Function expressions are not hoisted as callable functions: the variable is hoisted per its keyword (varundefined, let/const → TDZ), but the function value is only assigned at runtime. This is the critical distinction between function foo() {} and const foo = function() {}.
// Function declaration — fully hoisted, callable anywhere
greet(); // 'Hello' — works before declaration
function greet() { return 'Hello'; }

// Function expression with var — variable hoisted as undefined
// sayHi(); // TypeError: sayHi is not a function
var sayHi = function() { return 'Hi'; };
sayHi(); // works after assignment

// Function expression with const — TDZ
// sayBye(); // ReferenceError: Cannot access before initialization
const sayBye = () => 'Bye';

// Multiple function declarations — last one wins
function bar() { return 1; }
function bar() { return 2; }
console.log(bar()); // 2

Why it matters: Interviewers use this to distinguish candidates who know that calling a var-based function expression before assignment produces a TypeError (not ReferenceError), because the variable exists as undefined at that point.

Real applications: Node.js utility modules use function declarations so helper functions can appear after the main module.exports for readability; Express route handlers use function expressions for predictable initialization order.

Common mistakes: Treating var sayHi = function() {} as fully hoisted and calling it before assignment — it produces TypeError: sayHi is not a function because sayHi is undefined at that point, not a callable function.

The Temporal Dead Zone (TDZ) is the period from the start of a block to the point where a let or const declaration is initialized — during this window, accessing the variable throws a ReferenceError. Both let and const are hoisted (the engine registers them in scope), but unlike var they are not initialized until the declaration line is reached. Even typeof does not protect you — it only returns 'undefined' for truly undeclared variables, not for TDZ ones.
{
  // TDZ for 'x' starts here
  // console.log(x); // ReferenceError: Cannot access 'x' before init
  let x = 10;        // TDZ ends — binding initialized
  console.log(x);    // 10
}

// var has NO TDZ — just undefined
console.log(y); // undefined (hoisted as undefined)
var y = 20;

// typeof does NOT rescue from TDZ
let tdzVar = 1;
// typeof tdzVar before this line: ReferenceError (let in scope)

// TDZ in function default parameters
function test(a = b, b = 1) {}
// test(); // ReferenceError: b is in TDZ when a's default evaluates

// Self-referential TDZ
// const a = a; // ReferenceError: a in TDZ during its own init

Why it matters: TDZ errors are caught by ESLint's no-use-before-define rule statically; understanding TDZ explains why let/const are safer than var — they loudly fail instead of silently returning undefined.

Real applications: Vue 3 Composition API and React hooks rely on const declarations; TDZ explains why reactive variables must be declared before use in setup() functions, unlike var-based patterns in older frameworks.

Common mistakes: Assuming let and const are not hoisted at all — they are hoisted but not initialized, which is why a ReferenceError (not undefined) is thrown when accessed before declaration.

Class declarations are hoisted but not initialized — they sit in the TDZ from the start of the block until their declaration is reached, exactly like let and const. Attempting to instantiate a class before its declaration throws a ReferenceError, unlike function declarations which are fully hoisted with their body. Class expressions follow the same rules as their variable keyword (const, let, or var).
// new Person() before declaration throws ReferenceError (TDZ)
class Person {
  constructor(name) { this.name = name; }
  greet() { return `Hi, I'm ${this.name}`; }
}
const p = new Person('Alice'); // OK after declaration

// Compare: function declarations ARE fully hoisted
const f = new Foo(); // works — no TDZ for function declarations
function Foo() { this.x = 1; }

// Class expressions — same TDZ rules as their keyword
// new Animal() before this: ReferenceError (const TDZ)
const Animal = class {
  constructor(type) { this.type = type; }
};

// extends subject to TDZ — parent must be declared first
class Base {}
class Child extends Base {} // OK: Base is already initialized

Why it matters: Interviewers ask whether new MyClass() before its declaration throws a ReferenceError or TypeError — it is a ReferenceError (TDZ), which confirms that classes are hoisted but uninitialized like let/const.

Real applications: Angular decorators on classes and TypeScript's reflect-metadata depend on declaration order — class TDZ makes order-sensitive initialization a real production concern in large DI-based frameworks.

Common mistakes: Assuming classes behave like function declarations and can be instantiated anywhere in a file — they cannot; always declare a class before the first new call or before it is passed as a value to another function.

When both a var declaration and a function declaration share the same name, function declarations are hoisted first and win initially — the name resolves to the function at the start of execution. However, any var assignment at runtime then overwrites the function value. If multiple function declarations share a name, the last declaration wins during hoisting.
console.log(typeof foo); // 'function' — function hoisted above var

var foo = 'string';
function foo() { return 'fn'; }

console.log(typeof foo); // 'string' — var assignment ran at runtime

// What the engine does:
// 1. function foo() { return 'fn'; } — hoisted first
// 2. var foo; — same name, IGNORED (already declared)
// 3. console.log(typeof foo); // 'function'
// 4. foo = 'string'; // assignment overrides

// Last function declaration wins
function bar() { return 1; }
function bar() { return 2; }
console.log(bar()); // 2

// Avoid block-level function declarations (engine behavior varies)
if (true) {
  function baz() { return 'inside'; }
}
// baz() output is non-standard across engines — avoid!

Why it matters: Senior JavaScript interviews commonly present typeof foo before and after a var foo = 'string' + function foo(){} pattern — you must know the function wins at hoisting time but the assignment wins at runtime.

Real applications: Legacy jQuery plugins and WordPress themes mixed function declarations and var overrides at module level, causing subtle ordering bugs in concatenated script bundles where hoisting order was surprising.

Common mistakes: Assuming the var foo declaration overrides a function foo during hoisting — it is the runtime assignment (foo = 'string') that overrides, not the var declaration itself.

Function expressions are not hoisted as callable functions — the variable is hoisted per its keyword (varundefined, let/const → TDZ), but the function value is only assigned when execution reaches that line. This means calling a var-based function expression before its definition throws a TypeError (not a ReferenceError), while a const-based one throws a ReferenceError from the TDZ. Named function expressions expose the name only inside the function body.
// var function expression — variable hoisted as undefined
console.log(sayHi);    // undefined
// sayHi();             // TypeError: sayHi is not a function
var sayHi = function() { return 'Hi'; };
sayHi();               // 'Hi' — works after assignment

// const function expression — TDZ until declaration
// greet();             // ReferenceError: Cannot access before init
const greet = () => 'Hello';
greet();               // 'Hello'

// Named function expression — name scoped only inside itself
var calc = function multiply(x, y) { return x * y; };
calc(2, 3);            // 6
// multiply(2, 3);     // ReferenceError — name not visible outside

// Arrow functions follow the same rules as expressions
const add = (a, b) => a + b; // must be declared before use

Why it matters: The error type matters in interviews — TypeError: X is not a function (var expression) vs ReferenceError: Cannot access before initialization (const expression) reveals which declaration keyword was used and exactly how hoisting applied.

Real applications: React functional components are almost always const arrow function expressions (const App = () => <div/>), enforcing top-down declaration order and preventing use-before-define bugs that were common in older class component patterns.

Common mistakes: Reasoning that because var sayHi is hoisted (as undefined), calling sayHi() before the assignment is safe — it is not; invoking undefined as a function throws TypeError: sayHi is not a function.

let and const are block-scoped — they only exist within the nearest enclosing curly braces {}, including if, for, while blocks, and standalone blocks. var completely ignores block boundaries, escaping to the nearest enclosing function scope. The classic for-loop closure bug stems from var's function scope: all callbacks share one binding, while let creates a new binding per iteration.
if (true) {
  var a = 1;   // function-scoped — leaks out
  let b = 2;   // block-scoped — contained
}
console.log(a); // 1
// console.log(b); // ReferenceError

// Classic closure bug: var shares one binding (all log 3)
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // 3, 3, 3
}

// Fix: let creates a new binding per iteration
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0); // 0, 1, 2
}

// Standalone block for limiting variable lifetime
{
  const temp = expensiveComputation();
  doSomething(temp);
} // temp is now eligible for garbage collection

Why it matters: The var-in-loop setTimeout problem is one of the top 5 most-asked JavaScript interview questions; let's per-iteration binding is the canonical modern solution without needing an IIFE or extra wrapper function.

Real applications: React event handler loops in useEffect must use let/const to avoid stale closure bugs; Node.js stream processing uses block-scoped let variables to prevent callback-scope leaks that caused memory issues in older Express servers.

Common mistakes: Using var in for loops with async callbacks (setTimeout, fetch, event listeners) and expecting each callback to capture its iteration's value — only let creates a new binding per iteration.

At the top level of a script, var and function declarations create properties on the global object (window in browsers, global in Node.js), while let and const create bindings in a separate declarative environment record — accessible as variables but invisible on the global object. In ES modules, even var does not add properties to the global object because module code runs in its own scope.
var globalVar = 'hello';
let globalLet = 'world';

console.log(window.globalVar);  // 'hello' — on window
console.log(window.globalLet);  // undefined — NOT on window

// Function declarations also go on window
function utils() {}
console.log(typeof window.utils); // 'function'

// Danger: var can overwrite built-ins or third-party globals
var jQuery = 'oops';              // overwrites window.jQuery!
const myLib = { v: '1.0' };       // stays local, safe

// globalThis works cross-environment (browser + Node.js)
console.log(globalThis.globalVar); // 'hello'

// In ES modules: var does NOT go on window
// <script type="module"> creates its own isolated scope

Why it matters: Global namespace pollution is a critical production concern — var at module level can accidentally overwrite built-ins like window.name or window.location, causing hard-to-debug issues in large multi-script applications.

Real applications: Webpack and Vite bundle code as IIFE or ES modules specifically to prevent var declarations from polluting window; Google Analytics and Stripe.js use IIFE wrappers to keep their internal variables off the global object.

Common mistakes: Declaring module-level configuration with var in plain <script> tags, inadvertently creating a window property that clashes with a third-party library (window.name, window.status) loaded elsewhere on the page.

Output prediction questions test your ability to mentally simulate JavaScript's hoisting phase before execution: first hoist function declarations (fully), then var declarations (as undefined), then execute top to bottom. For let/const, identify TDZ boundaries. For var inside a function that shadows an outer var, the local var is hoisted as undefined within that function, hiding the outer variable immediately.
// Q1: var inside function shadows outer
var a = 1;
function foo() {
  console.log(a); // undefined — inner var 'a' hoisted, shadows outer
  var a = 2;
  console.log(a); // 2
}
foo();
console.log(a); // 1 — outer unchanged

// Q2: function vs var hoisting order
console.log(typeof x); // 'function' — function wins during hoisting
var x = 1;
function x() {}
console.log(typeof x); // 'number' — var assignment ran

// Q3: TDZ with let in nested block
let y = 'outer';
{
  // console.log(y); // ReferenceError — block's own y is in TDZ
  let y = 'inner';
  console.log(y);    // 'inner'
}
console.log(y); // 'outer'

Why it matters: Output prediction questions appear in nearly 80% of JavaScript technical screens; interviewers use hoisting quizzes to quickly identify candidates who truly understand execution order vs those who only write JavaScript without knowing why.

Real applications: Deep understanding of the JS execution model is essential when debugging minified production code in Chrome DevTools or tracing variable state in Webpack bundles where multiple script files are concatenated and declaration order changes.

Common mistakes: Forgetting that var a inside a function creates a local hoisted binding that immediately shadows any outer a — candidates often predict 1 for the first console.log(a) inside foo, but it logs undefined.

The modern consensus is const by default, let when reassignment is needed, and never var — this eliminates all hoisting surprises since const/let TDZ errors are loud and immediate while var's silent undefined creates hidden bugs. Declare all variables at the top of their scope for readability, and use function declarations only where their full hoisting is intentionally leveraged for top-down code organization.
// const by default — communicates immutable binding intent
const MAX_RETRIES = 3;
const config = { debug: false, env: 'prod' };
config.debug = true; // OK — mutating object, not reassigning binding

// let only when reassignment is genuinely needed
let count = 0;
let currentUser = null;
currentUser = fetchUser(); // intentional reassignment

// Never var
// var x = 1; // DON'T — function-scoped, hoisted as undefined

// Function declaration: deliberately hoistable helper
function bootstrapApp() {
  initDatabase();
  startServer();
}

// Function expression: use for callbacks and conditional assignment
const handler = (e) => e.preventDefault();
const logger = process.env.DEBUG ? console.log : () => {};

Why it matters: ESLint rules no-var, prefer-const, and no-use-before-define enforce these practices in CI pipelines; all major style guides (Airbnb, StandardJS, Google) prohibit var in modern codebases.

Real applications: TypeScript projects with strict: true and Next.js apps with ESLint presets enforce prefer-const by default; Create React App shipped these rules pre-configured, making var-free code the baseline expectation from day one.

Common mistakes: Using let for every variable "to be safe" instead of constconst should be the default because it communicates immutable binding intent and enables better optimization hints to JavaScript engines like V8.

The catch parameter (e.g., err) has been block-scoped to the catch block since ES3 — one of JS’s earliest examples of block scoping, predating let/const by two decades. Variables declared with var inside try or catch blocks are still hoisted to the enclosing function scope, while let/const are block-scoped to their respective block. Since ES2019, you can omit the catch binding entirely with optional catch binding: catch {}.
function example() {
  try {
    var x = 1;   // var hoisted to function scope
    let y = 2;   // let scoped to try block only
    throw new Error('test');
  } catch (err) {
    var z = 3;   // var hoisted to function scope
    console.log(err.message); // 'test'
    console.log(x);           // 1 — var from try, accessible
  }
  console.log(x); // 1 — var hoisted to function scope
  console.log(z); // 3 — var hoisted to function scope
  // console.log(err); // ReferenceError — catch param block-scoped
  // console.log(y);   // ReferenceError — let scoped to try
}

// ES2019: optional catch binding (no variable needed)
try {
  JSON.parse('{invalid}');
} catch {
  console.log('Invalid JSON — no binding needed');
}

Why it matters: Interviewers test try/catch scoping to distinguish candidates who know the catch parameter was always block-scoped (even before ES6), while var inside try/catch still escapes to function scope like any other block.

Real applications: Node.js async error handling pattern let result; try { result = await fetch(url); } catch (e) { result = fallback; } deliberately declares result outside the try block to make it available in the rest of the function.

Common mistakes: Assuming the err variable from catch (err) is accessible outside the catch block — it is not; the catch parameter has always been block-scoped, even though var declarations inside the same catch block escape to function scope.

When closures capture var variables, all closures share the same variable binding — at execution time they all see the variable’s final value, not the value at capture time. With let, each loop iteration creates an independent binding, so each closure correctly captures its own iteration’s value. This is the most famous hoisting + closure interaction bug in JavaScript.
// Classic bug: var — all closures share one 'i' binding
const funcs = [];
for (var i = 0; i < 3; i++) {
  funcs.push(() => i);
}
console.log(funcs[0]()); // 3 — all see final value
console.log(funcs[1]()); // 3
console.log(funcs[2]()); // 3

// Fix: let — new binding per iteration
const fixed = [];
for (let j = 0; j < 3; j++) {
  fixed.push(() => j);
}
console.log(fixed[0]()); // 0
console.log(fixed[1]()); // 1
console.log(fixed[2]()); // 2

// Pre-ES6 fix: IIFE captured the current value
for (var k = 0; k < 3; k++) {
  (function(captured) {
    funcs.push(() => captured);
  })(k);
}

Why it matters: This is the single most-discussed JavaScript closure bug in technical interviews; it directly demonstrates why let was introduced and why var is considered harmful in loop-heavy async code like event listener setup.

Real applications: React useEffect cleanup arrays and Node.js concurrent request handlers both hit this problem when written with var; migrating to let fixes stale closure issues without restructuring the surrounding code.

Common mistakes: Thinking the three closures in the var loop capture different values because they were created at different iterations — they all reference the same var i binding, which is 3 after the loop completes.

Variable shadowing occurs when an inner scope declares a variable with the same name as one in an outer scope, causing the inner binding to hide the outer one within that scope. The outer variable continues to exist unchanged and is accessible outside the inner scope. var cannot shadow a let/const in the same function scope (SyntaxError), but let can always shadow let in a nested block.
let x = 'outer';
{
  let x = 'inner'; // shadows outer x
  console.log(x);  // 'inner'
}
console.log(x);    // 'outer' — unchanged

// Parameter shadowing
const name = 'Alice';
function greet(name) {      // parameter shadows outer 'name'
  console.log(name);        // whatever is passed in
}
greet('Bob');               // 'Bob'
console.log(name);          // 'Alice' — outer unchanged

// var CANNOT shadow let in same function scope
let y = 1;
// { var y = 2; } // SyntaxError: y has already been declared

// let CAN shadow let in nested blocks
let z = 1;
{
  let z = 2; // valid — different block scope
  console.log(z); // 2
}
console.log(z); // 1

Why it matters: ESLint’s no-shadow rule flags unintentional shadowing, which can mask bugs where a developer intends to modify an outer variable but accidentally creates a new inner binding that is silently discarded after the block.

Real applications: React hooks frequently shadow outer variables (e.g., const [data, setData] = useState() inside a callback shadows an outer data variable), causing silent bugs where state updates do not propagate as expected.

Common mistakes: Declaring a variable inside an if/for body with the same name as an outer let and expecting mutations inside to affect the outer binding — the inner let is a separate binding, not a reference to the outer one.

The scope chain is the ordered series of lexical environments JavaScript searches when resolving a variable name — starting from the current scope and walking outward through each enclosing scope until the global scope, where a miss throws a ReferenceError. This chain is fixed at authoring time (lexical scoping): a function’s scope chain is determined by where it was written in source code, not where it is called. Closures work because functions carry their scope chain with them.
const global = 'global';

function outer() {
  const outerVar = 'outer';

  function inner() {
    const innerVar = 'inner';
    // Scope chain: inner → outer → global
    console.log(innerVar);  // 'inner'
    console.log(outerVar);  // 'outer' — found via chain
    console.log(global);    // 'global' — found in global scope
  }
  inner();
}
outer();

// Scope chain fixed at DEFINITION time, not call time
function create() {
  const x = 10;
  return () => x; // closes over create's scope chain
}
const fn = create();
const x = 999; // different x, does not affect fn
console.log(fn()); // 10 — uses lexical scope chain

Why it matters: The scope chain is foundational for understanding closures, module privacy, and why functions from different files can safely use the same local variable names without conflict; interviewers test this to verify genuine understanding of JavaScript’s execution model.

Real applications: JavaScript module systems (CommonJS, ES Modules) exploit lexical scope chains to create private module-level variables; Redux reducers rely on closure scope chains to encapsulate initial state without exposing it globally.

Common mistakes: Confusing lexical scope (fixed at write-time) with dynamic scope (determined at call-time) — JavaScript uses lexical scope for variables, but this is dynamically bound at the call site, which trips up many developers expecting consistent behavior.

var allows silent re-declaration in the same scope — the second declaration is ignored but assignments overwrite the value, a common source of typo-driven bugs. let and const throw a SyntaxError at parse time if you attempt to re-declare in the same scope, even if one uses var and the other let. const additionally prevents reassignment of the binding, though the value itself can be mutated if it is an object or array.
// var: silent re-declaration allowed
var x = 1;
var x = 2; // no error
console.log(x); // 2

// let: re-declaration throws SyntaxError (caught at parse time)
let y = 1;
// let y = 2; // SyntaxError: Identifier 'y' already declared

// const: no re-declaration AND no reassignment
const z = 1;
// const z = 2; // SyntaxError
// z = 2;       // TypeError: Assignment to constant variable

// const with objects: binding is const, content is mutable
const config = { debug: false };
config.debug = true;   // OK — mutating property
// config = {};        // TypeError — reassigning binding

// Re-declaration fine in DIFFERENT scopes (shadowing, not re-decl)
let b = 'outer';
{
  let b = 'inner'; // different scope — valid shadowing
  console.log(b);  // 'inner'
}
console.log(b); // 'outer'

Why it matters: The SyntaxError on let/const re-declaration is caught at parse time (before any code runs), making it a compile-time safety guarantee; this is why TypeScript and modern ESLint configs reject var — its silent re-declaration masks typo bugs that let/const would immediately surface.

Real applications: Large monorepo codebases at Meta and Airbnb enforce prefer-const and no-var in CI to prevent accidental re-declarations in shared utility files where a silent var override could corrupt a module’s exported function.

Common mistakes: Thinking const makes an object or array fully immutable — const only prevents rebinding (you cannot reassign the variable to a new object), but properties can still be modified unless you also call Object.freeze().

© 2026 InterviewPrep — JavaScript Interview Questions