JavaScript Scope and Hoisting: var, let, const and the TDZ
Understand global, function, block and lexical scope, how hoisting works for var, let, const and functions, and what the temporal dead zone means.
What you will learn
Why does one line of code see a variable while another says x is not defined? Why can you call some functions before you write them, but not others? The answers are scope and hoisting. They explain most “why is this undefined?” and “why does this crash?” moments for beginners. In this lesson you will learn the four kinds of scope (global, function, block and lexical), what hoisting does to var, let, const and functions, and what the temporal dead zone (TDZ) is.
What is scope?
Scope is the area of your code where a variable can be seen and used.
Think of a school building:
- The school notice board in the main hall can be read by everyone. (global)
- A classroom whiteboard can be read only by people inside that classroom. (function or block)
- A teacher inside a classroom can also read the notice board in the hall, but a person in the hall cannot read the whiteboard inside the classroom.
Scope protects your variables: names do not clash, and values do not change by accident.
Global scope
A variable created outside any function or block is global. It can be used everywhere in the file.
const siteName = "MyCodeNest";
function showSite() {
console.log(siteName); // can see the global variable
}
showSite(); // "MyCodeNest"
console.log(siteName); // "MyCodeNest"
Globals are handy for fixed values, but too many of them cause trouble:
- Two parts of the program may use the same name by accident.
- Any code can change them, so a bug could come from anywhere.
var at the top level becomes a property of window
In a normal browser script, a global var (and a global function) is attached to the window object, while let and const are not:
var oldGlobal = "I am on window";
let newGlobal = "I am not on window";
console.log(typeof window !== "undefined" && window.oldGlobal); // "I am on window" in a browser
console.log(typeof window !== "undefined" && window.newGlobal); // undefined in a browser
This is one more reason to prefer let and const.
Accidental globals
If you assign to a name that was never declared, JavaScript (in normal mode) quietly creates a global variable:
function calculate() {
total = 100; // forgot let or const!
}
calculate();
console.log(total); // 100 (leaked into the global scope)
In strict mode ("use strict", and also inside modules), this is an error: ReferenceError: total is not defined. Always declare variables with let or const.
Function scope
A variable created inside a function exists only inside that function. It is local.
function makeGreeting() {
const message = "Hello from inside";
console.log(message); // works
}
makeGreeting();
console.log(message); // ReferenceError: message is not defined
- Parameters are local variables too.
- Every call of a function creates a fresh set of local variables. Nothing is remembered between calls.
function count() {
let total = 0;
total++;
return total;
}
console.log(count()); // 1
console.log(count()); // 1 (a new "total" each time)
var is function-scoped
Variables created with var belong to the whole function, no matter which { } they were written in. That is the old behaviour, and the root of many bugs, as you will see next.
Block scope
A block is any code between { }: the body of an if, a for loop, a while loop, or just a plain pair of braces.
let and const are block scoped: they exist only inside the block where they were created. var ignores blocks.
if (true) {
var a = "I am var";
let b = "I am let";
const c = "I am const";
}
console.log(a); // "I am var" (leaked out of the block!)
console.log(b); // ReferenceError: b is not defined
console.log(c); // ReferenceError: c is not defined
Loops
for (var i = 0; i < 3; i++) {
// ...
}
console.log(i); // 3 (the var survived the loop)
for (let j = 0; j < 3; j++) {
// ...
}
console.log(typeof j); // "undefined" (j existed only inside the loop)
Block scope keeps temporary variables, such as loop counters, from leaking and being reused by mistake.
Scope summary
| Declared with | Scope | Redeclare in same scope? | Attached to window (global)? |
|---|---|---|---|
var |
Function | Yes | Yes |
let |
Block | No | No |
const |
Block | No | No |
Lexical scope and the scope chain
Lexical scope means a function’s scope is decided by where it is written in the code, not where it is called.
A function can use variables from its own scope and from every scope around it. When JavaScript looks for a variable, it follows the scope chain:
- Look in the current scope.
- If not found, look in the outer scope.
- Keep going outward until the global scope.
- If it is still not found, throw a
ReferenceError.
const level1 = "global";
function outer() {
const level2 = "outer";
function inner() {
const level3 = "inner";
console.log(level1, level2, level3); // "global outer inner"
}
inner();
}
outer();
inner can see all three variables, because it is written inside outer, which is written inside the global scope. The chain only goes outward. The global code cannot see level2 or level3.
Written position wins over call position
const name = "Global";
function show() {
console.log(name);
}
function test() {
const name = "Local";
show(); // called from inside test...
}
test(); // ...but prints "Global"
show was written in the global scope, so it uses the global name. It does not matter that it was called from test. This is the heart of lexical scope, and it makes closures (a later lesson) possible.
Shadowing
When an inner scope declares a variable with the same name as an outer one, the inner one hides (shadows) the outer one, but only inside that scope.
const color = "blue";
function paint() {
const color = "red"; // shadows the outer color
console.log(color); // "red"
}
paint();
console.log(color); // "blue" (the outer one is untouched)
It is legal, but it can confuse readers. Prefer different names, like selectedColor.
Hoisting
Before JavaScript runs your code, it makes a first pass and registers all the declarations in each scope. As a result, it seems like declarations are “moved to the top” of their scope. This is hoisting.
Hoisting works differently for functions, var, and let/const.
Function declarations are fully hoisted
You can call them before the line where they are written:
console.log(add(2, 3)); // 5 (works!)
function add(a, b) {
return a + b;
}
This lets you put your main code at the top and the helper functions below, which many people find easy to read.
var is hoisted, but starts as undefined
The declaration is hoisted, but the value is not:
console.log(score); // undefined (no error, which hides bugs)
var score = 10;
console.log(score); // 10
JavaScript treats the code as if you had written:
var score; // moved to the top
console.log(score); // undefined
score = 10;
console.log(score); // 10
let and const are hoisted too, but cannot be used early
console.log(age); // ReferenceError: Cannot access 'age' before initialization
let age = 20;
They are registered, but kept uninitialised until the line where you declare them. That “forbidden” period is the temporal dead zone.
Function expressions and arrow functions are not hoisted like declarations
The variable follows the rules of var, let or const:
// With const (or let): ReferenceError
console.log(typeof greet); // throws, because greet is in the TDZ
const greet = () => "Hello";
// With var: the variable exists but is undefined, so calling it fails
sayHi(); // TypeError: sayHi is not a function
var sayHi = function () {
return "Hi";
};
Hoisting at a glance
| What | Can you use it before its line? | What you get |
|---|---|---|
function name() {} |
Yes | Works normally |
var x = 1 |
Yes, but | undefined |
let x = 1 / const x = 1 |
No | ReferenceError (TDZ) |
const f = () => {} / const f = function () {} |
No | ReferenceError (TDZ) |
var f = function () {} |
Variable yes, function no | TypeError: f is not a function |
class A {} |
No | ReferenceError (TDZ) |
The temporal dead zone (TDZ)
The temporal dead zone is the time between the start of a scope and the line where a let or const is declared. If you touch the variable during that time, you get a ReferenceError.
{
// TDZ for "city" starts here
// console.log(city); // ReferenceError: Cannot access 'city' before initialization
let city = "Delhi"; // TDZ ends here
console.log(city); // "Delhi"
}
It is called temporal because it depends on time (the order in which the lines run), not on position alone.
Proof that let is hoisted
If let were not hoisted, the next code would print 10, the outer value. It crashes instead, because the inner let x was registered at the top of its block and is still in the TDZ:
let x = 10;
{
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 20;
}
Why does the TDZ exist?
The TDZ helps you. Instead of silently giving undefined like var, JavaScript stops you immediately and tells you that you used a variable too early. The bug is easy to find.
Even typeof, which is usually safe, is not safe inside the TDZ:
console.log(typeof neverDeclared); // "undefined" (safe for names that do not exist at all)
{
// console.log(typeof inTdz); // ReferenceError!
let inTdz = 1;
}
Reading the error messages
| Message | What it usually means |
|---|---|
ReferenceError: x is not defined |
There is no variable x in any scope you can see (a typo, or it lives in an inner scope) |
ReferenceError: Cannot access 'x' before initialization |
You used a let / const before its line (TDZ) |
x is undefined (no error) |
A var used before its value was set, a missing argument, or a missing property |
TypeError: x is not a function |
x exists but holds something else, such as undefined from a hoisted var |
TypeError: Assignment to constant variable |
You tried to reassign a const |
Best practices
- Use
constby default,letwhen the value changes. Avoidvar. - Declare variables at the top of their scope, and as close to where they are used as makes sense.
- Keep scopes small. Small functions and blocks mean fewer places for bugs to hide.
- Avoid globals. Pass values into functions as parameters instead.
- Always declare variables. Use
"use strict"or modules to catch accidental globals. - Use different names, rather than shadowing.
- Define function expressions and arrow functions before you call them.
Real-life use cases
1. A var loop counter that leaks
function findFirstNegative(numbers) {
for (var i = 0; i < numbers.length; i++) {
if (numbers[i] < 0) {
break;
}
}
return i; // "works" only because var leaks out of the loop
}
console.log(findFirstNegative([4, 8, -2, 9])); // 2
It works by accident. With let, the i would be gone after the loop, and you would see the mistake at once. Write the intention clearly instead:
function findFirstNegativeIndex(numbers) {
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] < 0) {
return i;
}
}
return -1;
}
console.log(findFirstNegativeIndex([4, 8, -2, 9])); // 2
2. Main code on top, helpers below
Function declarations are hoisted, so you can read the program in order:
const total = calculateTotal([250, 99, 1499]);
console.log(formatPrice(total)); // "₹1848.00"
function calculateTotal(prices) {
let sum = 0;
for (const price of prices) {
sum += price;
}
return sum;
}
function formatPrice(amount) {
return "₹" + amount.toFixed(2);
}
3. Block scope in if / else
const score = 82;
if (score >= 40) {
const message = "You passed!";
console.log(message);
} else {
const message = "Try again!"; // a different variable: no clash
console.log(message);
}
Each block has its own message, with no conflict.
4. Shadowing bug
let total = 0;
const prices = [100, 200, 300];
for (const price of prices) {
let total = price * 1.18; // oops: a NEW total, which shadows the outer one
}
console.log(total); // 0 (not what we wanted!)
The fix is to remove the inner let, so the loop updates the outer variable:
let total = 0;
for (const price of [100, 200, 300]) {
total += price * 1.18;
}
console.log(total.toFixed(2)); // "708.00"
5. Temporary variables that stay private
function getAverage(numbers) {
let sum = 0;
for (const n of numbers) {
sum += n;
}
return sum / numbers.length;
}
console.log(getAverage([70, 80, 90])); // 80
// "sum" is gone after the function finishes, so it cannot clash with anything else
6. Separate variables in switch cases
Every case shares one block, so repeated let names clash. Wrap each case in its own braces:
const action = "square";
const number = 4;
switch (action) {
case "square": {
const result = number * number;
console.log(result); // 16
break;
}
case "double": {
const result = number * 2; // OK: separate block
console.log(result);
break;
}
}
Common mistakes
- Using a variable outside the block or function where it lives.
ReferenceError: x is not defined. - Forgetting
letorconst.total = 5creates an accidental global. - Using
varand expecting block scope.varleaks out ofifblocks and loops. - Using
letorconstbefore the declaration line. The TDZ throws aReferenceError. - Calling a function expression or arrow function before it is created. Only
functiondeclarations are hoisted. - Reusing a variable name in an inner scope without noticing. It shadows the outer variable.
- Believing that hoisting physically moves your code. It does not. It is how JavaScript registers declarations before running.
- Relying on hoisting to hide messy ordering. Write code in the order it should be read.
- Thinking
constmakes a variable global-safe. A globalconstcan still be read from everywhere.
Practice
-
Predict the output, then run it:
var a = 1; function test() { console.log(a); var a = 2; } test();Why is it
undefinedand not1? -
Predict the output:
console.log(hello()); function hello() { return "Hello!"; } -
Fix this code so it does not crash, without changing the function’s body:
console.log(double(4)); const double = (n) => n * 2; -
What does each
console.logprint?const x = "global"; function outer() { const x = "outer"; function inner() { console.log(x); } inner(); } outer(); console.log(x); -
Explain why this code throws an error, even though there is a
let xfurther up:let x = 5; function show() { console.log(x); let x = 10; } show(); -
Write a loop with
varand another withlet. After each loop, print the counter and describe the difference. -
Rewrite this to avoid an accidental global:
function addTax(price) { result = price * 1.18; return result; } -
Put the main logic of a small program at the top and its helper functions below. Explain why it works.
-
Find and fix the shadowing bug in a loop that tries to add up numbers with
let sum = 0declared inside the loop. -
Challenge: without running it, write the output of the program below, then check yourself:
let value = "global"; function first() { console.log(value); } function second() { let value = "second"; first(); console.log(value); } second();
Recap
- Scope decides where a variable can be used: global (everywhere), function (inside the function) and block (inside
{ }, forletandconst). - Lexical scope: a function uses the variables from where it was written, following the scope chain outward until the global scope. Inner code can see outer variables, but not the other way round.
- Shadowing hides an outer variable with an inner one of the same name. Avoid it.
varis function-scoped, leaks out of blocks and can be redeclared.letandconstare block-scoped. Useconstby default.- Hoisting: declarations are registered before code runs. Function declarations work fully before their line.
vargivesundefined.let,constand classes cannot be used early. - The temporal dead zone is the time between the start of a scope and the declaration of a
letorconst. Touching the variable then throws aReferenceError. - Always declare your variables, keep scopes small, and avoid globals.
- Next you will learn callbacks and higher-order functions: passing functions to other functions.