Scope and hoisting
The last lesson ended on a puzzle: a function declaration can be called from
above the line that defines it, and a const arrow cannot. The answer explains
several things at once — including the var leak from module 2 and an error
message that reads like a contradiction.
Scope is where a name is visible
const shopName = 'Anna Tiffins';
function greet() {
const message = 'Namaste';
console.log(`${message} from ${shopName}`);
}
greet();
console.log(message);
Namaste from Anna Tiffins
ReferenceError: message is not defined
shopName is at the top level, so everything can see it. message lives inside
greet, so nothing outside can.
Scope goes inwards only. An inner scope can see outward; an outer scope cannot see in. This is not a restriction to work around — it is what makes a function safe to use without reading it. Its internals cannot collide with yours.
Scopes nest as deeply as you like, and a lookup walks outwards until it finds the name or runs out:
const rate = 80;
function week() {
const days = 7;
function describe() {
console.log(`${days} days at ₹${rate}`);
}
describe();
}
week();
7 days at ₹80
describe finds days one level out and rate two levels out.
This is lexical scope: what a name means is decided by where the code is written, not by where it is called from. That sentence is the foundation of the next lesson, so it is worth sitting with.
Shadowing
An inner name with the same spelling hides the outer one:
const rate = 80;
function special() {
const rate = 60;
console.log(rate);
}
special();
console.log(rate);
60
80
The outer rate is untouched. Shadowing is legal and occasionally useful, and it
is also a good way to confuse yourself. If two things in view have the same name
and different meanings, rename one.
Block scope, again
Module 2 showed that let and const are scoped to the nearest braces and var
is scoped to the whole function. That is the same rule as above, applied to
blocks rather than functions:
function check(plates) {
if (plates > 0) {
const status = 'active';
}
console.log(status);
}
check(3);
ReferenceError: status is not defined
Declare it outside the block if you need it outside the block. With var this
"works", which is precisely the problem — the name escapes and collides.
Hoisting
Now the puzzle. This runs:
console.log(orderTotal(3, 80));
function orderTotal(plates, rate) {
return plates * rate;
}
240
Before running your code, JavaScript scans the scope and sets up every declaration it finds. Function declarations are set up completely — name and body — so they work from anywhere in their scope. This is called hoisting, which is a slightly misleading name: nothing physically moves, the declarations are simply processed first.
var is hoisted too, but only the name:
console.log(plates);
var plates = 3;
undefined
Not an error — undefined. The name existed from the start of the scope; the
value only arrived at line two. A variable that is mysteriously undefined at
the top of a function and correct at the bottom is this, and it is a genuinely
nasty bug because nothing complains.
The temporal dead zone
let and const are hoisted as well — but reaching them early is an error
rather than undefined:
console.log(plates);
let plates = 3;
ReferenceError: Cannot access 'plates' before initialization
Read that message carefully, because it is doing you a favour. It does not
say "plates is not defined". It says the name exists and you are too early. The
gap between the start of the scope and the declaration line is called the
temporal dead zone, and it exists on purpose: turning a silent undefined
into a loud error is the whole improvement.
Those two messages are worth telling apart:
| Message | Means |
|---|---|
x is not defined |
No such name anywhere in scope. Usually a typo. |
Cannot access 'x' before initialization |
The name exists; you used it above its let/const. Move the use down, or the declaration up. |
This is also why a const arrow cannot be called early:
orderTotal(3, 80);
const orderTotal = (plates, rate) => plates * rate;
ReferenceError: Cannot access 'orderTotal' before initialization
The function is a value assigned to a const, and the const is in its dead
zone. Nothing to do with arrows — a const holding a number behaves identically.
What to do about all this
Declare things before you use them. Then hoisting never affects you and you never have to reason about it. The knowledge is for reading other people's code and for understanding the two error messages, not for writing clever code.
| Declaration | Hoisted? | Before its line |
|---|---|---|
function f() {} |
Name and body | Works |
var x |
Name only | undefined |
let x / const x |
Name only, in the dead zone | ReferenceError |
class C {} |
Name only, in the dead zone | ReferenceError |
Check your work
A name declared inside a function is invisible outside it —
ReferenceError: message is not defined. Scope goes inwards only.
An inner function can read names from every enclosing scope. describe
prints 7 days at ₹80 by finding days one level out and rate two.
Shadowing prints 60 then 80. The inner const rate hides the outer one
inside that function and leaves it untouched outside.
const inside an if block is not visible after the block —
ReferenceError: status is not defined. With var it would be visible, which
is the leak from module 2.
Calling a function declaration before its line works and prints 240.
Declarations are hoisted whole.
console.log(plates) above var plates = 3 prints undefined, not an
error. The name was hoisted, the value was not.
console.log(plates) above let plates = 3 gives ReferenceError: Cannot access 'plates' before initialization — a different message from plates is not defined, and the difference tells you whether you have a typo or an
ordering problem.
A const arrow called before its line fails the same way, because it is a
const in its dead zone, not because it is an arrow.
The show() puzzle prints undefined. var rate is hoisted to the top of
the function, so at the console.log the name exists with no value yet. The
outer rate, if there is one, is not consulted — the local declaration wins for
the whole function, even above its own line. Two fixes:
function show() {
var rate = 80;
console.log(rate);
}
function show() {
console.log(rate);
let rate = 80;
}
The first moves the line and prints 80. The second changes the keyword and
turns the silent undefined into ReferenceError: Cannot access 'rate' before initialization — which does not print 80 either, but tells you the truth
instead of a plausible lie. That is the trade the second fix is making, and it
is usually the one you want.
Practice
-
Write a function with a local
constand try to read it from outside. Read the error. -
Nest three functions and have the innermost print a name from the outermost. Confirm it works, then move that name inside a sibling function and watch it break.
-
Shadow an outer
ratewith an inner one. Print both and confirm the outer is unchanged. -
Declare a
constinside anifblock and use it afterwards. Read the error. Then fix it by declaring it outside withlet. -
Call a function declaration from above its definition. Then convert it to a
constarrow and watch the same call fail. -
Produce both error messages on purpose. First
notAThingforis not defined, then aletused above its line forCannot access ... before initialization. Say out loud what each one is telling you to change. -
Print a
varabove its assignment and getundefined. Then convert it toletand get an error instead. Decide which you would rather have at 11pm. -
Harder. This prints
undefinedrather than80. Work out why, then fix it in two different ways — one that moves a line, one that changes a keyword.function show() { console.log(rate); var rate = 80; } show();
Next: closures — what happens when an inner function outlives the scope it came from. This is the idea people find hardest, and it follows directly from lexical scope.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship