B3 · Part 1 · Beginner
var, let and const
Three keywords, four questions they answer differently — where a name lives, when you can touch it, whether it can change, and whether you can declare it twice.
09On this page
A declaration makes a binding
Every one of these three keywords does the same basic job: it creates a binding — a name, and a box the name points at. What differs is the rules around that box. And the rules only make sense once you see that a binding goes through three separate moments, which most explanations squash into one:
- Created — the name exists in some scope.
- Initialised — the box gets its first value, and the name becomes safe to read.
- Assigned — the box gets a new value later on.
| Created | Initialised | Assigned again | |
|---|---|---|---|
var |
when the function (or script) starts | at the same moment, to undefined |
any time |
let |
when its block starts | when its own line runs | any time |
const |
when its block starts | when its own line runs — and it must have an = |
never |
Nearly everything else on this page falls out of that table. The gap
between "created" and "initialised" is the Temporal Dead Zone, which
the execution context chapter
introduced. "When the function starts" versus "when its block starts"
is the whole difference in scope. And the last column is all
const actually promises.
Say it like this → "All three create a
binding. They differ in where the binding lives, when it becomes
readable, and whether it can be pointed somewhere else."
Question 1 — where does the name live?
let and const belong to the nearest pair of
braces around them — the nearest block. var does
not see blocks at all. It climbs up to the nearest function
(or the whole script, at the top level) and is created there, however
deep inside an if you wrote it.
Step through one function and watch which box each name lands in — and which box disappears.
Function scope — shop()
Block scope — the if { }
Console
Which braces count as a block
The body of an if or else; a
for, while or do loop;
try, catch and finally; and a
bare { } you typed on purpose. A function body is a scope
for all three keywords — var climbs to a
function, never out of one:
function setup() {
var inside = 1;
}
setup();
typeof inside; // "undefined" — var escapes blocks, not functions
Two sets of braces are not what they look like. The braces of an
object literal are not a block — they hold properties, not
declarations. And a switch body is one
block, however many case labels it has, so every
let in every case shares one scope:
switch (action) {
case "add":
let total = 1; // declared in the switch's single block
break;
case "reset":
total = 0; // ReferenceError when action is "reset" — its let line never ran
break;
}
Add a third case with its own let total = 2 and it gets
worse: same block, same name twice, so it is a SyntaxError and the
file does not run at all. The fix is to give each case its own braces:
case "add": { let total = 1; break; }. Now every case is
a real block with its own names.
Shadowing — and the TDZ trap hiding inside it
Declaring a name in an inner block that already exists outside is allowed. The inner one shadows the outer: inside the block, the name means the inner binding; outside, the outer one is exactly as it was. Now predict this one — it is a favourite in interviews because almost everyone gets it wrong:
let level = "outer";
{
console.log(level); // what happens?
let level = "inner";
}
ReferenceError, not "outer". The inner
level is created the moment the block starts, so it is
already shadowing the outer one on the console.log line
— it is just still in its TDZ. The lookup finds the inner name, stops
there, and throws. It never falls through to the outer
"outer". With var in place of the inner
let the rules change again, which is the next problem.
Dry run: which binding level means, line by line
| Step | Line | outer level | inner level | Lookup finds |
|---|---|---|---|---|
| 1 | let level = "outer"; | "outer" | does not exist yet | — |
| 2 | { — block entered | "outer" | created, uninitialised — TDZ | — |
| 3 | console.log(level) | "outer" | TDZ | the inner one — nearest scope wins, even though it is still in its TDZ — so it throws |
| — | let level = "inner"; | "outer" | — | never runs |
The lookup never gets a chance to fall back to the outer
"outer" — it stops at the nearest declaration first,
exactly as shadowing always works, and only then notices that
binding is not ready to be read yet.
A var cannot hide inside a block
Because var climbs to the function, a var
in a block can collide with a let that sits outside it:
function f() {
let x = 1;
{
var x = 2; // SyntaxError: Identifier 'x' has already been declared
}
}
It looks like shadowing. It is not — the var x is really
declared on the function, right next to let x, and two
declarations of one name in one scope is a redeclaration. The reverse
is fine: a let inside a block really does live in the
block, so it shadows an outer var without complaint.
Question 2 — when can you touch it?
var is readable from the first line of its function, and
holds undefined until assigned. let,
const and class are unreadable until their
own line runs. Reading, writing and even typeof all
throw. That window is the TDZ, and
the execution context chapter
covers why it exists. Two consequences are worth taking further.
The TDZ is about time, not position
The word in the name is temporal. What matters is whether
the line has run yet, not whether the code that reads the
name sits above or below it in the file. A function written above a
let can read it perfectly well, as long as it is called
after:
function read() { return value; } // written ABOVE the let
try { read(); } catch (e) { console.log("too early:", e.constructor.name); }
let value = 1;
console.log("after:", read()); // what happens?
too early: ReferenceError, then after: 1.
The same function, the same line of source, two different results —
decided purely by when it was called. This is why a callback
can safely mention a let declared further down the file:
by the time anything calls it, the line has run.
A name is in its TDZ during its own initialiser
let x = x + 1; // ReferenceError — the right-hand x is the NEW x, still in its TDZ
let count = 5;
{
let count = count + 1; // ReferenceError too — same reason, via shadowing
}
The second one catches people trying to "copy the outer value in".
The inner count already exists when the right-hand side
runs, so it shadows the outer one. Use a different name for the inner
binding.
Question 3 — can it change?
const makes one promise: this name will always point
at the same value. That is a promise about the name. It says
nothing about whether the value on the other end can be edited.
const user = { name: "ana" };
user.name = "bob"; // fine — same object, different contents
user = { name: "bob" }; // TypeError — a different object for the same name
const list = [1, 2];
list.push(3); // fine — [1, 2, 3]
list = []; // TypeError: Assignment to constant variable.
For numbers, strings and booleans the distinction is invisible,
because primitives cannot be edited in place anyway —
const on a primitive really is a fixed value. For objects
and arrays, the thing that is fixed is the arrow, not the target.
Three errors, three moments
| You wrote | Error | When it fires |
|---|---|---|
const a; |
SyntaxError: Missing initializer | before any line runs |
a = 2 on a const |
TypeError: Assignment to constant variable | only when that line actually runs |
reading a above const a = 1 |
ReferenceError (TDZ) | only when that line actually runs |
The middle row surprises people: reassigning a const is a
runtime error. if (false) { a = 2; } never
throws, because the line never runs. A linter will still flag it —
the engine will not, until it happens.
Freezing the value too
If you want the contents locked as well, that is
Object.freeze — with two catches worth knowing.
const settings = Object.freeze({ theme: "dark", sizes: { base: 16 } });
settings.theme = "light"; // ignored — still "dark"
settings.sizes.base = 20; // works! freeze is one level deep
- It is shallow. Nested objects are still ordinary objects. To freeze all the way down you walk the object and freeze every level — that is the exercise at the end of this chapter.
-
It fails silently in sloppy mode. The assignment above just
does nothing. In strict mode — including every ES module — it throws
TypeError: Cannot assign to read only property. Silent failure is the worse of the two.
Question 4 — can you declare it twice?
var x = 1;
var x = 2; // fine. no warning, no error, and now you have a bug to find
console.log("does this line run?");
let y = 1;
let y = 2; // SyntaxError: Identifier 'y' has already been declared
The var version is not a typo-catcher, and that is the
point of the newer keywords: a name declared twice in one scope is
almost always two people (or two afternoons) fighting over the same
variable.
Look at when the let version fails, though. The
console.log above it never prints. This is not a runtime
error on line 6 — the duplicate is found while names are being
collected, before a single line runs, so the whole script is
rejected. It is the cleanest proof there is that
your code is read twice.
| In one scope | Result |
|---|---|
var a + var a |
allowed, silently |
var a + function a() {} |
allowed — the function wins the creation phase |
let / const / class a + anything named a |
SyntaxError, whichever comes first |
| the same name in an inner block | allowed — that is shadowing, not redeclaring |
Loops — where the difference is loudest
Same loop, one keyword changed, two completely different results. Step through it and watch the boxes, not the numbers — the answer is about how many bindings exist, not about timing.
With var — bindings that exist
With let — bindings that exist
Calling all three at the end
const withVar = [];
for (var i = 0; i < 3; i++) withVar.push(function () { return i; });
const withLet = [];
for (let j = 0; j < 3; j++) withLet.push(function () { return j; });
console.log(withVar.map(function (f) { return f(); }));
console.log(withLet.map(function (f) { return f(); }));
Run it to confirm: [3, 3, 3] then [0, 1, 2].
Swap the stored functions for setTimeout callbacks and it
is the same bug in its most common form — every timer reads the one
shared var box, long after the loop has finished with it.
Before let existed the fix was a wrapper function per
iteration — an IIFE, covered in Scope.
What "a fresh binding per iteration" means exactly
It is not "each iteration gets the loop variable's value frozen".
Each iteration gets its own live box, and at the end of the
pass the current value is copied into the next iteration's box
before i++ runs. So a change made inside the body
still counts — both for the closure and for the loop:
const seen = [];
for (let i = 0; i < 6; i++) {
seen.push(function () { return i; });
i++; // an extra step, inside the body
}
console.log(seen.map(function (f) { return f(); })); // what happens?
[1, 3, 5]. Iteration one's box starts at 0, the body
bumps it to 1, and the closure — reading the live box — sees 1. That 1
is copied into iteration two's box, and only then does the header's
i++ make it 2. Three passes, three boxes, each closure
seeing its own box's final value.
const in a loop
for (const n of [1, 2, 3]) console.log(n * 2); // fine — a fresh const every pass
for (const k in { a: 1, b: 2 }) console.log(k); // fine, for the same reason
for (const i = 0; i < 3; i++) console.log(i); // prints 0, THEN TypeError on i++
for...of and for...in make a new binding each
pass and never reassign it, so const is the natural
choice there. A counting loop has to reassign its counter — the body
runs once, and the first i++ throws. A let
inside a while body is fresh on every pass too, simply
because the body is a block that is entered again each time.
At the top of a file
At the top level of a classic script, var and function
declarations become properties of the global object —
window in a browser. let, const
and class do not.
var greeting = "hi";
let name = "ana";
window.greeting; // "hi"
window.name; // "" — NOT "ana". let stayed off window; this is the tab's own name property
delete window.greeting; // false — a var-created global cannot be deleted
count = 0; // no keyword: a plain property, and delete window.count works
That window.name line is a real trap, not a contrived
one: window already has hundreds of properties
(name, status, top,
length...), and a top-level var with one of
those names silently collides with the browser's own.
var name = 42; typeof name gives "string"
— you did not make a variable, you set the tab's name, and the tab's
name only stores strings.
Staying off window does not mean "private to this file",
though. Every classic <script> on a page shares
one top-level scope for let and
const as well — it is just a scope that is not an object.
<!-- a.js --> let config = { theme: "dark" };
<!-- b.js --> let config = { debug: true };
// SyntaxError: Identifier 'config' has already been declared
// — and none of b.js runs, not even its first line
var config in b.js collides the same way.
The upgrade over two vars is real — a loud crash instead
of a silent overwrite — but the collision is still there. Only
modules (<script type="module">, or any file with
import/export) give each file a top-level
scope of its own. Why the global scope is split into an object half
and a non-object half is in Scope, the
next chapter.
Which one to write
Rule
const until the code forces
you to reassign, then let. var only when you
are reading someone else's old file.
const first is not about speed. It is a note to the next
reader: this name means the same thing on every line below. When
every binding that can be const is, the few
lets left stand out as the values that actually move —
which is exactly where bugs live. ESLint's prefer-const
and no-var rules enforce all of this for you.
var is worth reading fluently, because a lot of real code
still uses it and every quirk on this page shows up in that code. It
is not worth writing: there is nothing var does that
let does not do more safely.
The traps, collected
-
Reading an outer name above an inner
letof the same name — ReferenceError, not the outer value. The inner one already shadows it from the top of the block. -
let x = x + 1— the right-handxis the new one, still in its TDZ. -
A
varloop with callbacks inside — one shared box, read after the loop finished with it. -
letin twocases of oneswitch— one block, so it is a redeclaration. Brace each case. -
Expecting
constto freeze an object — it locks the arrow, not the target. AndObject.freezeonly goes one level down. -
constin a countingforloop — runs once, then throws oni++. -
Top-level
varnamed like awindowproperty —name,status: you are writing to the browser's global, not your own. -
Assuming
letis private per file — classic scripts share one top-level scope. Only modules separate them.
Say it like this → "
var is
function-scoped and initialised to undefined straight away.
let and const are block-scoped and stay in
the TDZ until their line runs. const fixes the binding,
not the value. And redeclaring a let is a parse-time
error, so the whole script never starts."
Interview answer, one sentence
"var is function-scoped and hoisted as undefined, let and const are block-scoped and sit in the temporal dead zone until declared, and const prevents rebinding, not mutation — so default to const, use let when you must reassign, and avoid var."
Before you move on
- Fill in the created / initialised / assigned-again table for
var,letandconstfrom memory. - Explain why
console.log(level)throws instead of printing the outer value, for aletshadowed later in the same block. - Predict, and explain, why a
varloop's callbacks all read the final value while aletloop's callbacks each read their own. - Say why
const user = {}; user.name = "x";is legal butuser = {}is not — what exactly doesconstfix? - Explain why a top-level
var nameis a real trap in a classic script but a top-levellet nameis not.
Opens in the editor: write it, run it, and check it against real tests.
All 35 for this level →Finished reading?
0 of 41 chapters read in this series