Writing · MARGIN NOTESFront-End Development

Yes, let Is Hoisted: Hoisting and the Temporal Dead Zone

"let isn't hoisted" is the answer that costs candidates points. All declarations are hoisted; the difference is initialization. Here is the interview mental model for var, let, const, functions, classes, and the TDZ.

Author
Nicola Riker Urdaneta
Published
October 20, 2026
Reading time
7 min read
Yes, let Is Hoisted: Hoisting and the Temporal Dead Zone
Contents

Ask ten candidates about hoisting and most will say "var is hoisted, let and const are not." It sounds right, it gets nods in tutorials, and it is the answer that tells an interviewer your mental model is slightly off.

Every declaration is hoisted. What differs is whether the binding is usable before its line runs. Once you see hoisting as two steps (create the bindings, then run the code), the temporal dead zone, the typeof trap, and the classic loop bug all fall out of the same rule.

Why interviewers ask

The prompt usually looks like one of these:

  • What does this log? (a console.log above a var, then above a let)
  • Are let and const hoisted? What is the temporal dead zone?
  • Why can I call a function declaration before it appears, but not a function expression?
  • Why does typeof throw here when it returns "undefined" for a variable that was never declared?

They are testing four things at once:

  • Do you know that scope setup happens before any code in that scope runs?
  • Can you separate "the binding exists" from "the binding has a value"?
  • Can you predict which declaration forms are usable early (function declarations), give undefined early (var), or throw early (let, const, class)?
  • Do you understand why the TDZ exists: to turn silent bugs into loud ones?

Core mental model: create bindings, then run code

The spec doesn't describe hoisting as moving code. What actually happens: when the engine enters a scope (a script, a module, a function body, a block), it first creates a binding for every name declared in that scope. Only then does it start executing statements top to bottom.

The forms differ in what each binding holds at that moment:

  • var: created and initialized to undefined, scoped to the whole function (or the whole script or module at the top level)
  • function declaration: created and initialized to the function object itself
  • let and const: created but left uninitialized, scoped to the enclosing block
  • class declaration: created but left uninitialized, block scoped, like let

Reading an uninitialized binding throws a ReferenceError. The stretch of time between entering the scope and running the declaration is the temporal dead zone (TDZ).

JavaScript
console.log(a); // undefined
var a = 1;
console.log(a); // 1

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 1;

Read the error message closely (this is V8's wording, the engine in Chrome and Node; Firefox and Safari phrase it differently but mean the same thing). It does not say "b is not defined." It says the engine knows about b and refuses to let you read it yet. That message is your proof that let is hoisted.

The TDZ is about time, not position

The "temporal" part matters. The TDZ ends when the declaration executes, not at a line number. A function written above a let can read it, as long as the function is called after the declaration has run.

JavaScript
function readCount() {
  return count;
}

let count = 3;
readCount(); // 3: the declaration already ran

// Swap the order and the same function throws:
// readCount(); let count = 3; // ReferenceError

Say out loud: the question is never "is the reference above the declaration in the file?" The question is "has the declaration executed yet when this line runs?"

Classic gotcha: typeof is not safe in the TDZ

typeof on a name that was never declared returns "undefined" without throwing. That is why old feature-detection code uses it. Inside the TDZ, typeof throws, because the binding exists but has no value.

JavaScript
typeof notDeclaredAnywhere; // "undefined"

typeof later; // ReferenceError: Cannot access 'later' before initialization
let later = 1;

This is the follow-up interviewers like most, because it separates people who memorized "let throws early" from people who understand bindings.

Classic gotcha: function declarations vs expressions

A function declaration is hoisted with its body, so you can call it before it appears. A function expression is just a value assigned to a variable, so it follows the rules of whatever variable holds it.

JavaScript
sayHi(); // "hi": declaration is initialized with the function
function sayHi() {
  return "hi";
}
JavaScript
sayHello(); // TypeError: sayHello is not a function
var sayHello = function () {
  return "hello";
};
JavaScript
greet(); // ReferenceError: Cannot access 'greet' before initialization
const greet = () => "hey";

Notice the two different errors. The var version fails with a TypeError because sayHello exists and holds undefined, and calling undefined is the problem. The const version fails with a ReferenceError because reading greet itself is the problem. Being able to explain why the error types differ is a strong signal.

Classes behave like let, not like function declarations. new Widget() above class Widget {} throws a ReferenceError, even though classes are functions under the hood.

Classic gotcha: shadowing and self-reference

Because a block creates its bindings before running, an inner let shadows an outer variable for the entire block, including the lines above the inner declaration.

JavaScript
let x = 1;
{
  console.log(x); // ReferenceError, not 1
  let x = 2;
}

The inner x exists from the opening brace, so console.log(x) sees the inner binding in its TDZ, not the outer x. The same rule explains two other traps:

JavaScript
let total = total + 1; // ReferenceError: the right side reads total in its TDZ
JavaScript
function f(a = b, b = 1) {
  return [a, b];
}
f(); // ReferenceError: parameter b is still in its TDZ when a's default runs

Default parameters are evaluated left to right, so function f(a = 1, b = a) works and returns [1, 1].

Classic gotcha: var in loops

The same scoping rules explain the most famous closure question. var is function scoped, so a for (var ...) loop has a single i shared by every callback. let in a for header gets a fresh binding for each iteration.

JavaScript
const withVar = [];
for (var i = 0; i < 3; i++) {
  withVar.push(() => i);
}
withVar.map((fn) => fn()); // [3, 3, 3]

const withLet = [];
for (let j = 0; j < 3; j++) {
  withLet.push(() => j);
}
withLet.map((fn) => fn()); // [0, 1, 2]

If the interviewer pushes on why, connect it to closures: each arrow closes over a binding, not a value. With var there is one binding; with let there are three.

Two edge cases worth knowing

Redeclaration is checked before any code runs. var a; var a; is allowed, but let a; let a; (or mixing let and var for the same name in one scope) is a SyntaxError and nothing in that script executes. That early failure is another sign the engine scans declarations up front.

Function declarations inside blocks are block scoped in strict mode (modules and classes are strict). In sloppy-mode scripts, engines apply legacy web-compatibility rules that also leak the function to the enclosing scope, so code outside the block can see it after the block runs. Don't rely on that; if a function belongs to a block, use a const with an arrow or function expression.

Why the TDZ exists

var hides ordering bugs. Read a var too early and you get undefined, which flows into math as NaN or into conditions as false, and the failure shows up somewhere else. The TDZ makes the same mistake throw on the exact line that caused it. It also makes const meaningful: a const that could be observed as undefined before its assignment would not really be constant.

What to say out loud in an interview

A strong answer sounds like this:

  1. All declarations are hoisted: when a scope is entered, the engine creates bindings for every declared name before running code.
  2. var bindings start as undefined and are function scoped. Function declarations start as the function itself.
  3. let, const, and class bindings start uninitialized and are block scoped. Reading them before the declaration executes throws a ReferenceError. That window is the temporal dead zone.
  4. The TDZ is about execution time, not line position. A function can reference a later let if it runs after the declaration.
  5. typeof is only safe for undeclared names. Inside the TDZ, it throws too.

Then walk one small demo: console.log above a var, then above a let, and read the two results aloud.

Common wrong answers to avoid

  • "let and const are not hoisted." No. They are hoisted but uninitialized. The error message ("before initialization") proves the binding exists.
  • "Hoisting physically moves declarations to the top of the file." No. Nothing moves. Bindings are created when the scope is entered; the code stays where it is.
  • "typeof never throws." No. It throws for a let, const, or class binding in its TDZ.
  • "Function expressions are hoisted like function declarations." No. Only the variable is hoisted, and it follows var, let, or const rules.
  • "Classes are functions, so they hoist like functions." No. Class declarations have a TDZ, like let.
  • "The TDZ lasts until the line where the variable is declared." Close, but the precise version is: until the declaration executes. Order of execution, not order in the file.

Takeaways

  • Hoisting is two steps: create bindings for the whole scope, then run code.
  • var starts as undefined; function declarations start as the function; let, const, and class start uninitialized.
  • The TDZ is the time between entering the scope and executing the declaration. Reads inside it throw.
  • If an early call throws TypeError: x is not a function, you hit a var holding undefined. If it throws ReferenceError ... before initialization, you hit the TDZ.
  • let in a for header creates a fresh binding per iteration; var shares one.

If you can say "hoisted, but in the TDZ," show the two error messages, and explain the loop difference, you have answered the question the interviewer was really asking.

Hire Nicola to build your app

Nicola builds apps for clients: scoping the work, shaping the experience, and shipping systems that fit. See pricing or get in touch if you have a product, site, or front-end system in mind.

About the author

Nicola Riker is an Awwwards-nominated design engineer based in Colorado Springs. She plans, designs, and builds digital products for clients.

Keep going: closures explains why each loop callback sees its own binding, and the 4 rules for this, the event loop, promises, and prototypes are covered in earlier notes on this blog.