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
undefinedat 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
leton scope and the TDZ, plus: it must be initialised at declaration, and the BINDING cannot be reassigned. It says nothing about the value. Aconstobject'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, sonew Thing()above theclass 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.
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
varbindings are initialised toundefinedduring the creation pass, reading one early gives youundefinedrather 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
letearly throw instead of giving undefined?Because
letandconstare 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.02Is
lethoisted?Yes. People say it isn't, and that's the wrong answer.
letandconstare hoisted - they're just not initialised, so they're unusable until their declaration line runs.You can prove it without running anything special: if
letwere not hoisted, alet xinside a block would leave an outerxvisible above it in that block. It doesn't - the innerletshadows 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.03What is the Temporal Dead Zone?
The region of a scope from the top of the block down to the line where a
let,constorclassis 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
letcan 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
letto undefined likevar?Because
consthas to be impossible to observe before its value exists - otherwise you could read aconstasundefined, which would mean a constant had two values in its lifetime. Givenconstneeds it,letgot the same treatment for consistency. The side benefit is that use-before-declaration becomes a loud error instead of a silentundefinedthat surfaces three functions later.04What's the difference between
var,letandconst?Scope:
varis function-scoped,letandconstare block-scoped. Hoisting: all three hoist, butvarinitialises toundefinedwhileletandconstsit in the TDZ. Redeclaration:varallows it,letandconstare a SyntaxError. Reassignment:varandletyes,constno.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?
constby default,letwhen the binding genuinely has to change,varnever in new code.constis not about immutability - it is about telling the reader this name will not be pointed somewhere else, which is true of most names.05Does
constmake an object immutable?No.
constfreezes 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.
constsays that reference can't be replaced. Everything reachable through it is untouched by the keyword. Soconst arr = []; arr.push(1)is fine, andarr = []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.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 onlyf, set toundefined, so calling it early givesTypeError: f is not a function.const f = () => {}putsfin the TDZ, so calling it early gives a ReferenceError. Two different errors, same underlying rule: only the variable hoists, never the value.07Are class declarations hoisted?
Yes, and they're in the TDZ.
new Thing()aboveclass Thing {}throws "Cannot access 'Thing' before initialization" - not "Thing is not defined".Classes behave exactly like
letfor 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.08What kinds of scope does JavaScript have?
Global, function (or module), and block.
varsees only the first two;letandconstsee all three.Modules add a fourth practical layer: top-level
let,constand evenvarin 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.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.10What's the difference between "undefined" and "not defined"?
undefinedmeans 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:
undefinedlogged,x is not defined, orCannot access 'x' before initialization.11What does
typeofreturn for a variable that was never declared?The string
"undefined", with no error.typeofis 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 xwherexis aletdeclared 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.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.13What does
"use strict"change?Undeclared assignments throw instead of creating globals,
thisisundefinedin 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
thispuzzles have two answers depending on it - and the modern answer is usually the strict one.14Can you redeclare a variable?
varyes, as many times as you like - the second declaration is a no-op and only the assignment takes effect.letandconstno: 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.
15Does a block create a scope for
var?No.
varignores blocks entirely - anif,foror 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
varinside anifis visible to the whole function after it, so two unrelated branches sharing an obvious name likeiortempsilently share the same variable.16Why does
istill exist after afor (var i = 0; …)loop?Because
var iis scoped to the enclosing function, not the loop. When the loop ends,iholds its final value and is still readable.Follow-up And with
let?let iis scoped to the loop, so reading it after givesReferenceError: i is not defined. More than that -letcreates a NEW binding per iteration, which is what makes closures inside the loop capture different values.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 xhalfway down, the outerxis 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.18What is an IIFE and why did people write them?
An Immediately Invoked Function Expression - a function defined and called on the spot,
(function () { … })(). Beforeletand 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
awaitin 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.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
#privateclass 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.
20Do
vardeclarations at the top level become properties ofwindow?In a classic script, yes - top-level
varand function declarations attach to the global object. Top-levellet,constandclassdo not: they live in a separate script scope you can't reach throughglobalThis.So
var x = 1; console.log(window.x)gives1, andlet y = 1; console.log(window.y)givesundefined, even thoughyis perfectly readable by name. In an ES module none of them attach - module top-level scope is the module's own.21Is a function declaration inside an
ifblock 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
letinstead. 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."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.
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,
letwould 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.24Can you use
constin a loop?In
for…ofandfor…in, yes - each iteration gets a fresh binding, soconstis fine and is the right default. In a classicfor (let i = 0; i < n; i++)no, because the increment reassigns.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.
26Why did
letandconstget added whenvaralready worked?Three concrete bugs:
varleaking out of blocks,vargiving one shared binding to every closure in a loop, and use-before-declaration failing silently asundefined. 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.
27Is there any case for
varin new code?No. Not one that isn't better served by
let. If you findvarin a codebase it's either pre-2015 or it was transpiled.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
letworld that's a SyntaxError, which is one more argument for function expressions assigned toconst.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
letfrom the bottom, call it at the very end, and it works fine. Call it before the declaration line and the same function throws.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 takethislexically - fixed so many bugs.31Why does
typeofon a TDZ variable throw whentypeofis 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
typeofgets a value to inspect.32How would you check whether a global exists without crashing?
typeof someName !== 'undefined', or'someName' in globalThisif you only care about real globals. A bareif (someName)throws when it doesn't exist.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 withconstor a TypeError withvar. Arrow functions are values, not declarations.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 initializationBoth 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 initializationtypeof 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 functionThe 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 definedvar 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
innerThe 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 initializationClass 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 definedvar 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
0A 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
numberWith 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
hereDefining 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
secondBoth 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
outerinner 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 thingbalanceis 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.balancesilently 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.typeofon it does not.- That exemption does NOT save you from a TDZ variable - only from a name that exists nowhere.