Index

Scope, hoisting and the TDZ

This is the warm-up question and the one most people answer with a phrase they do not believe. "Declarations are moved to the top" is a story about text; nothing moves. What actually happens is that the engine walks a scope once before it runs a line of it, creates every binding it finds, and initialises some of them. Which ones it initialises is the whole difference between var, let, const, function and class - and it is the difference the follow-up question is about.

The engine runs every scope twice

Before a single statement in a function (or a module, or a block) executes, the engine scans that scope and builds its environment record: a table of every name declared in it. That is the creation phase. Only then does it run the code top to bottom - the execution phase. Hoisting is not code moving. It is the fact that the table exists before line one runs.

The names go into that table at the same moment. What differs is their starting value. A var is created AND initialised to undefined. A function declaration is created AND initialised to the finished function. A let, const or class is created but left UNINITIALISED - the name exists, the slot is empty, and touching it throws. That empty window is the Temporal Dead Zone, and it ends the instant the declaration line runs.

This is why the two behaviours you have to explain are not two different rules. It is one rule with three settings: initialised to undefined, initialised to a value, or not initialised at all.

var
Created and set to undefined at scope entry. Scoped to the nearest function (a block does nothing to it). Redeclarable. Reassignable. At the top level of a classic script it also becomes a property of the global object.
let
Created but uninitialised at scope entry, so it is in the TDZ until its line runs. Block-scoped. Redeclaring it in the same scope is a SyntaxError. Reassignable.
const
Identical to let on scope and the TDZ, plus: it must be initialised at declaration, and the BINDING cannot be reassigned. It says nothing about the value. A const object's properties are freely mutable.
function declaration
Created and fully initialised at scope entry - the body and all. This is the one case where calling before the line genuinely works, and the reason people believe the "moved to the top" story.
class
Hoisted like let: the binding exists from scope entry but is uninitialised, so new Thing() above the class Thing {} line throws a ReferenceError, not "Thing is not defined".

Scope chain, in one sentence

When the engine cannot find a name in the current scope's table, it looks at the scope that lexically ENCLOSED that code in the source file, then the one enclosing that, up to global, and throws a ReferenceError if it runs out. That chain is fixed when you write the code, not when you call it - which is what "lexical scope" means and why moving a function call somewhere else never changes which variables it can see.

Say "lexical" out loud in the answer. It is the word that distinguishes scope (decided by where the function is WRITTEN) from this (decided by how the function is CALLED), and interviewers reach for that contrast constantly.

Questions

Answer each one yourself before you open it.

  1. 01What is hoisting?

    Before any code in a scope runs, the engine registers every declaration in that scope, so the names exist from the first line. Nothing physically moves - what's hoisted is the declaration, never the assignment.

    The engine processes each scope in two passes: a creation pass that builds the environment record for every declared name, then an execution pass that runs the statements. Because var bindings are initialised to undefined during the creation pass, reading one early gives you undefined rather than an error. The value on the right-hand side is produced in the execution pass, on the line where you wrote it.

    Follow-up Then why does reading a let early throw instead of giving undefined?

    Because let and const are registered but deliberately left uninitialised. The name is in the table, so it is not "not defined" - the slot is empty, and reading an empty slot throws. That window is the Temporal Dead Zone.

  2. 02Is let hoisted?

    Yes. People say it isn't, and that's the wrong answer. let and const are hoisted - they're just not initialised, so they're unusable until their declaration line runs.

    You can prove it without running anything special: if let were not hoisted, a let x inside a block would leave an outer x visible above it in that block. It doesn't - the inner let shadows from the top of the block, which is only possible if the binding was already registered. That shadow-from-the-top region is exactly the TDZ.

  3. 03What is the Temporal Dead Zone?

    The region of a scope from the top of the block down to the line where a let, const or class is declared. The name exists there but has no value, and touching it throws a ReferenceError.

    "Temporal" rather than "spatial" is the exact word: the zone is about time, not position. A function defined above a let can read it perfectly well as long as it is CALLED after the declaration has run. It is the moment of access that matters, not the line number.

    Follow-up Why have a TDZ at all - why not just default let to undefined like var?

    Because const has to be impossible to observe before its value exists - otherwise you could read a const as undefined, which would mean a constant had two values in its lifetime. Given const needs it, let got the same treatment for consistency. The side benefit is that use-before-declaration becomes a loud error instead of a silent undefined that surfaces three functions later.

  4. 04What's the difference between var, let and const?

    Scope: var is function-scoped, let and const are block-scoped. Hoisting: all three hoist, but var initialises to undefined while let and const sit in the TDZ. Redeclaration: var allows it, let and const are a SyntaxError. Reassignment: var and let yes, const no.

    Answer it as those four axes in that order - scope, hoisting, redeclaration, reassignment. It is a four-part answer to a four-part question and it takes fifteen seconds, which is why it is a good filter.

    Follow-up Which should you reach for?

    const by default, let when the binding genuinely has to change, var never in new code. const is not about immutability - it is about telling the reader this name will not be pointed somewhere else, which is true of most names.

  5. 05Does const make an object immutable?

    No. const freezes the binding, not the value. You cannot point the name at a different object, but you can add, change and delete properties freely.

    The variable holds a reference. const says that reference can't be replaced. Everything reachable through it is untouched by the keyword. So const arr = []; arr.push(1) is fine, and arr = [] throws a TypeError.

    Follow-up So how do you actually make it immutable?

    Object.freeze(obj) - but that is shallow: nested objects are still mutable, and in non-strict code a write to a frozen object fails silently instead of throwing. A deep freeze means recursing, and most codebases are better served by just not mutating.

  6. 06Are function declarations hoisted?

    Fully - name and body. You can call a function declaration from above the line that declares it and it works.

    During the creation pass a function declaration is both registered and initialised to the completed function object. That is the only declaration form that gets its real value before execution starts, and it exists so that mutually recursive functions and top-down-readable files work without forward declarations.

    Follow-up What about function expressions and arrow functions?

    Not hoisted as functions. var f = function () {} hoists only f, set to undefined, so calling it early gives TypeError: f is not a function. const f = () => {} puts f in the TDZ, so calling it early gives a ReferenceError. Two different errors, same underlying rule: only the variable hoists, never the value.

  7. 07Are class declarations hoisted?

    Yes, and they're in the TDZ. new Thing() above class Thing {} throws "Cannot access 'Thing' before initialization" - not "Thing is not defined".

    Classes behave exactly like let for binding purposes. This matters practically because a class body can extend an expression (class A extends getBase()), which has to be evaluated at the declaration line - so the engine cannot possibly have a usable class before that line runs.

  8. 08What kinds of scope does JavaScript have?

    Global, function (or module), and block. var sees only the first two; let and const see all three.

    Modules add a fourth practical layer: top-level let, const and even var in an ES module are scoped to the module, not the global object. That's why a script converted to a module suddenly can't see a global that another file declared - nothing broke, the leak stopped.

  9. 09What is the scope chain?

    The ordered list of enclosing scopes the engine searches when it needs a name: current scope, then the scope that lexically contains it, and so on up to global. Not found by the top means ReferenceError.

    Follow-up Is the chain built where the function is written or where it's called?

    Where it's written. That's lexical scoping, and it is the opposite of this, which is decided at call time. If you remember one contrast from this topic, remember that one.

  10. 10What's the difference between "undefined" and "not defined"?

    undefined means the binding exists and holds no value yet. "not defined" means there is no binding at all - the engine searched the whole scope chain and found nothing.

    The third state is the TDZ: the binding exists, holds nothing, and reading it throws. So there are really three answers to "does this name work here", and the error message tells you which: undefined logged, x is not defined, or Cannot access 'x' before initialization.

  11. 11What does typeof return for a variable that was never declared?

    The string "undefined", with no error. typeof is the one operator that can safely touch a name that doesn't exist.

    That exception was in the language from the start so feature-detection code (if (typeof someGlobal !== 'undefined')) wouldn't crash on browsers that lacked the global. It is genuinely the only safe way to probe a name you're not sure exists.

    Follow-up Does that exception apply to a variable in the TDZ?

    No - and this is the trap. typeof x where x is a let declared later in the same scope throws a ReferenceError. The safety hatch only covers names that are truly absent from every scope, not names that exist but are uninitialised.

  12. 12What happens if you assign to a variable you never declared?

    In sloppy mode it silently creates a property on the global object. In strict mode - which includes every ES module - it throws a ReferenceError.

    This is the single biggest reason strict mode exists. A typo in an assignment used to create a new global instead of failing, and the bug then showed up somewhere unrelated, much later. Note the asymmetry: the accidental global is a global object PROPERTY, so it is deletable, unlike a real var.

  13. 13What does "use strict" change?

    Undeclared assignments throw instead of creating globals, this is undefined in a plain function call instead of the global object, duplicate parameter names and octal literals are syntax errors, and silent failures on frozen or read-only properties become TypeErrors.

    You almost never type it any more because every ES module and every class body is strict automatically. It matters in interviews because half the classic this puzzles have two answers depending on it - and the modern answer is usually the strict one.

  14. 14Can you redeclare a variable?

    var yes, as many times as you like - the second declaration is a no-op and only the assignment takes effect. let and const no: a duplicate in the same scope is a SyntaxError.

    The important bit is WHEN that SyntaxError happens. It's thrown at parse time, before any code runs, so the whole file fails and nothing before it logs. People expect a runtime error at the second line; there isn't one, because there is no first line.

  15. 15Does a block create a scope for var?

    No. var ignores blocks entirely - an if, for or bare {} does nothing to it. It's scoped to the nearest enclosing function, or global if there isn't one.

    That is precisely the bug block scoping was added to fix. A var inside an if is visible to the whole function after it, so two unrelated branches sharing an obvious name like i or temp silently share the same variable.

  16. 16Why does i still exist after a for (var i = 0; …) loop?

    Because var i is scoped to the enclosing function, not the loop. When the loop ends, i holds its final value and is still readable.

    Follow-up And with let?

    let i is scoped to the loop, so reading it after gives ReferenceError: i is not defined. More than that - let creates a NEW binding per iteration, which is what makes closures inside the loop capture different values.

  17. 17What is variable shadowing?

    Declaring a name in an inner scope that already exists in an outer one. The inner one wins for the whole inner scope, and the outer one is unreachable there.

    The gotcha is that shadowing starts at the TOP of the inner block, not at the declaration line. So in a block that has let x halfway down, the outer x is not readable in the lines above it either - you get a TDZ error instead of the outer value. That surprises people who expect a gradual takeover.

  18. 18What is an IIFE and why did people write them?

    An Immediately Invoked Function Expression - a function defined and called on the spot, (function () { … })(). Before let and modules it was the only way to get a private scope, because functions were the only thing that made one.

    Two jobs: keep helper variables out of the global object, and give a script a place to put private state. Both are now done by block scope and modules respectively, so writing one in new code is a smell - it usually means someone learned the pattern before 2015.

    Follow-up Is there any reason to write one today?

    Rarely. The honest one is to get a top-level await in a non-module script, via an async IIFE. Everything else - privacy, avoiding globals, one-off initialisation - is better served by a module or a block.

  19. 19What is the module pattern?

    An IIFE that closes over some private variables and returns an object exposing only the functions you want public. It's how you got encapsulation before ES modules and before #private class fields.

    It's worth being able to write because it's the clearest demonstration that scope is the privacy mechanism in JavaScript. Nothing is marked private - the state is simply not reachable, because no reference to it escapes the function.

  20. 20Do var declarations at the top level become properties of window?

    In a classic script, yes - top-level var and function declarations attach to the global object. Top-level let, const and class do not: they live in a separate script scope you can't reach through globalThis.

    So var x = 1; console.log(window.x) gives 1, and let y = 1; console.log(window.y) gives undefined, even though y is perfectly readable by name. In an ES module none of them attach - module top-level scope is the module's own.

  21. 21Is a function declaration inside an if block hoisted?

    It's block-scoped in modern strict code, so it only exists inside that block. In sloppy mode browsers apply a messy legacy compatibility rule where the name leaks to the function scope but the value doesn't arrive until the declaration runs.

    The practical answer is: never conditionally declare a function. Assign a function expression to a let instead. The one-line version for an interviewer is "it's a known corner of the spec that behaves differently in sloppy mode, so I don't write it."

  22. 22What is an execution context?

    The bookkeeping the engine creates for each running piece of code: its scope's environment record, a link to the enclosing scope, and its this. Every function call pushes one onto the call stack and popping it discards its locals.

    Say "the creation phase builds the environment record" and "execution contexts stack" and you've said everything hoisting questions are actually reaching for. It also sets up the async topic - the stack must be empty before the event loop can run a queued callback.

  23. 23Does hoisting mean my code is physically rewritten?

    No. Nothing is moved or rewritten. The engine just knows every declaration in a scope before running it, because it scanned the scope first.

    The "moved to the top" phrasing is a teaching model, and it breaks the moment you meet the TDZ - if declarations really moved up, let would work early. Explaining hoisting as a pre-scan rather than a rewrite is the single fastest way to signal you understand it rather than remember it.

  24. 24Can you use const in a loop?

    In for…of and for…in, yes - each iteration gets a fresh binding, so const is fine and is the right default. In a classic for (let i = 0; i < n; i++) no, because the increment reassigns.

  25. 25What's the difference between a declaration and an initialisation?

    The declaration is the name entering the scope; the initialisation is the value arriving. Hoisting moves the first and never the second - that one sentence answers most of this topic.

  26. 26Why did let and const get added when var already worked?

    Three concrete bugs: var leaking out of blocks, var giving one shared binding to every closure in a loop, and use-before-declaration failing silently as undefined. Block scope fixes the first two, the TDZ fixes the third.

    Framing it as "here are the three bugs it fixed" instead of "block scoping is better" is what makes this sound like experience rather than a bullet list. All three are things you can demonstrate in four lines.

  27. 27Is there any case for var in new code?

    No. Not one that isn't better served by let. If you find var in a codebase it's either pre-2015 or it was transpiled.

  28. 28What happens with two function declarations of the same name?

    The last one wins, everywhere in the scope, including above it. Both are processed in the creation phase and the later one overwrites the earlier.

    Which means a duplicated function name is a silent bug: the earlier definition is unreachable and there's no warning. In a let world that's a SyntaxError, which is one more argument for function expressions assigned to const.

  29. 29Can a function read a variable declared after it?

    Yes, as long as it's called after that declaration has run. The TDZ is about the moment of access, not about where the function was defined.

    This is the cleanest demonstration that "temporal" is the operative word. Define a function at the top that reads a let from the bottom, call it at the very end, and it works fine. Call it before the declaration line and the same function throws.

  30. 30What does "lexical scope" mean?

    Which variables a piece of code can see is determined by where it sits in the source text, and is fixed when you write it. Calling a function from somewhere else never changes what it can see.

    Follow-up What's the counterexample in the language?

    this. It's the one thing decided at call time rather than write time, which is exactly why arrow functions - which take this lexically - fixed so many bugs.

  31. 31Why does typeof on a TDZ variable throw when typeof is supposed to be safe?

    Because the safety only covers names that don't exist. A TDZ name does exist - the engine finds it in the scope table and then hits the uninitialised slot, which throws before typeof gets a value to inspect.

  32. 32How would you check whether a global exists without crashing?

    typeof someName !== 'undefined', or 'someName' in globalThis if you only care about real globals. A bare if (someName) throws when it doesn't exist.

  33. 33Do arrow functions hoist?

    The variable holding one hoists like any let/const/var; the function itself does not. So calling one above its line gives a ReferenceError with const or a TypeError with var. Arrow functions are values, not declarations.

  34. 34Where does a closure fit into all this?

    A closure is what happens when the scope chain outlives the call that created it. The environment record isn't discarded when the function returns, because an inner function still holds a reference to it.

    Worth saying because it connects the two topics: hoisting is about how the environment record is BUILT, closures are about how long it LIVES. Same data structure, two questions about it.

Predict the output

The output is hidden. Predict it before you open it.

The classic: two reads, two different behaviours

function sayHi() {
  console.log(name);
  console.log(age);
  var name = 'Ayush';
  let age = 21;
}
sayHi();
Show the output
undefined
ReferenceError: Cannot access 'age' before initialization

Both name and age are registered when sayHi is entered. var name is initialised to undefined, so the first log prints it. let age is registered but left uninitialised, so the second log hits the TDZ and throws - and because it throws, the rest of the function never runs.

typeof is safe on one of these and not the other

console.log(typeof neverDeclaredAnywhere);
console.log(typeof later);
let later = 1;
Show the output
undefined
ReferenceError: Cannot access 'later' before initialization

typeof skips the ReferenceError only for names that are absent from the entire scope chain. later is not absent - it's declared in this scope and merely uninitialised, so the lookup succeeds, finds an empty slot, and throws before typeof can report anything.

Declaration vs expression

hoisted();
notHoisted();

function hoisted() {
  console.log('I ran');
}
var notHoisted = function () {
  console.log('I did not');
};
Show the output
I ran
TypeError: notHoisted is not a function

The declaration is initialised to its function during the creation phase, so calling it early is fine. var notHoisted is initialised to undefined, so the call is undefined() - a TypeError, not a ReferenceError, because the binding does exist.

Blocks and var

if (true) {
  var a = 1;
  let b = 2;
}
console.log(a);
console.log(b);
Show the output
1
ReferenceError: b is not defined

var a ignores the block and belongs to the enclosing scope, so it survives. let b belongs to the block and is gone with it - and note the error is "is not defined", not a TDZ message, because outside the block the binding genuinely does not exist.

Shadowing from the top of the scope

var x = 'outer';
function f() {
  console.log(x);
  var x = 'inner';
  console.log(x);
}
f();
Show the output
undefined
inner

The inner var x shadows the outer one for the whole function, starting at the first line. So the first log reads the local binding, which at that point is undefined - the outer 'outer' is unreachable inside f no matter where the declaration sits.

const and a mutation

const user = { name: 'first' };
user.name = 'second';
console.log(user.name);
user = { name: 'third' };
Show the output
second
TypeError: Assignment to constant variable.

const protects the binding, not the object. Changing a property is untouched by it; pointing the name at a new object is not. This is the most common const misconception and it is a two-line demo.

A class above its declaration

const t = new Thing();
class Thing {}
Show the output
ReferenceError: Cannot access 'Thing' before initialization

Class declarations hoist with let semantics - the binding exists from the top of the scope but is uninitialised. The message is the giveaway: "cannot access before initialization" means the name was found, which is a different failure from "Thing is not defined".

Nothing logs at all

console.log('start');
let b = 1;
let b = 2;
Show the output
SyntaxError: Identifier 'b' has already been declared

'start' never prints. A duplicate let in the same scope is caught when the file is parsed, before a single statement executes, so the whole script fails rather than failing on the third line. With var the same file would print start and finish silently.

Loop counters after the loop

for (var i = 0; i < 3; i++) {}
console.log(i);
for (let j = 0; j < 3; j++) {}
console.log(j);
Show the output
3
ReferenceError: j is not defined

var i belongs to the enclosing function scope and holds the value that failed the condition - 3, not 2. let j belongs to the loop and is discarded with it.

Blocks really do scope

let count = 0;
{
  let count = 5;
  console.log(count);
}
console.log(count);
Show the output
5
0

A bare block is a real scope for let. The inner count is a separate binding that shadows the outer one for the block's duration and is then discarded. Swap both to var and this logs 5 then 5.

An accidental global, in a plain script

function f() {
  leaked = 10;
}
f();
console.log(leaked);
console.log(typeof leaked);
Show the output
10
number

With no var, let or const, sloppy mode walks the scope chain, finds nothing, and creates a property on the global object. In strict mode - so in any ES module or class body - the same assignment throws ReferenceError: leaked is not defined, which is the behaviour you want.

Calling into the future

function read() {
  return value;
}
let value = 'here';
console.log(read());
Show the output
here

Defining read above the let is fine - it only matters when it RUNS. By the time it's called the declaration has executed, so the binding is initialised. Move the console.log above the let line and the same function throws.

Two declarations, one name

function greet() {
  return 'first';
}
console.log(greet());
function greet() {
  return 'second';
}
Show the output
second

Both declarations are processed in the creation phase, in source order, so the second overwrites the first before any line runs. The call in the middle already sees the later definition - a duplicate function name is silently unreachable code.

The scope chain, one level at a time

let v = 'global';
function outer() {
  let v = 'outer';
  function inner() {
    console.log(v);
  }
  inner();
}
outer();
Show the output
outer

inner has no v, so the engine goes to the scope that lexically encloses it, which is outer, and stops there. It never reaches the global one. Nothing about where inner is CALLED enters into it.

Write it from memory

Complete, runnable code. Practice typing it from memory before your interview.

The module pattern

Asked as "how would you make a private variable without a class?". It's four lines and it demonstrates that scope is the privacy mechanism.

const bank = (function () {
  let balance = 0;

  function deposit(amount) {
    if (amount <= 0) throw new Error('amount must be positive');
    balance += amount;
    return balance;
  }

  function withdraw(amount) {
    if (amount > balance) throw new Error('insufficient funds');
    balance -= amount;
    return balance;
  }

  return { deposit, withdraw, get balance() { return balance; } };
})();

bank.deposit(100);
bank.withdraw(30);
console.log(bank.balance); // 70
console.log(bank.internalBalance); // undefined - there is no such thing
  • balance is not marked private. It is unreachable, because no reference to it leaves the function.
  • The getter is there so reads work but writes don't - assigning to bank.balance silently fails in sloppy mode and throws in strict.
  • In new code this is a module with a module-scoped let, or a class with #balance. Know the pattern; don't ship it.

A safe global check

The one place typeof's ReferenceError exemption is actually load-bearing.

function hasFeature(name) {
  return typeof globalThis[name] !== 'undefined';
}

// The bare-name version, for a global you cannot index by string:
if (typeof __DEV_TOOLS__ !== 'undefined' && __DEV_TOOLS__.enabled) {
  console.log('dev tools on');
}
  • if (__DEV_TOOLS__) on an undeclared name throws. typeof on it does not.
  • That exemption does NOT save you from a TDZ variable - only from a name that exists nowhere.
Closures, this, and currying →