Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Beginner/B3
3 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSB3 · 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.

14 min read9 sections2 exercises
3 of 41

In this chapter

  • A declaration makes a binding
  • Question 1 — where does the name live?
  • Question 2 — when can you touch it?
  • Question 3 — can it change?
  • Question 4 — can you declare it twice?

and 4 more

about 14 min
09On this page
  1. A declaration makes a binding
  2. Question 1 — where does the name live?
  3. Question 2 — when can you touch it?
  4. Question 3 — can it change?
  5. Question 4 — can you declare it twice?
  6. Loops — where the difference is loudest
  7. At the top of a file
  8. Which one to write
  9. The traps, collected

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.

Where each name lives — one function, two scopes
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

StepLineouter levelinner levelLookup finds
1let level = "outer";"outer"does not exist yet—
2{ — block entered"outer"created, uninitialised — TDZ—
3console.log(level)"outer"TDZthe 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.

var vs let in a loop — the same code, two memories
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 let of 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-hand x is the new one, still in its TDZ.
  • A var loop with callbacks inside — one shared box, read after the loop finished with it.
  • let in two cases of one switch — one block, so it is a redeclaration. Brace each case.
  • Expecting const to freeze an object — it locks the arrow, not the target. And Object.freeze only goes one level down.
  • const in a counting for loop — runs once, then throws on i++.
  • Top-level var named like a window property — name, status: you are writing to the browser's global, not your own.
  • Assuming let is 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, let and const from memory.
  • Explain why console.log(level) throws instead of printing the outer value, for a let shadowed later in the same block.
  • Predict, and explain, why a var loop's callbacks all read the final value while a let loop's callbacks each read their own.
  • Say why const user = {}; user.name = "x"; is legal but user = {} is not — what exactly does const fix?
  • Explain why a top-level var name is a real trap in a classic script but a top-level let name is not.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsWould this scope even parse?beginner
  • 8 testsMake const mean itbeginner
All 35 for this level →
B3

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. B2Previous · B2Execution context
  2. B3you are here
  3. Next · B4ScopeWhich names a line of code can see, why that is decided by where you wrote it, and how JavaScript finds a name — for reading and for writing.15 min read

01On this page

9 sections · 0%

  1. 01A declaration makes a binding
  2. 02Question 1 — where does the name live?
  3. 03Question 2 — when can you touch it?
  4. 04Question 3 — can it change?
  5. 05Question 4 — can you declare it twice?
  6. 06Loops — where the difference is loudest
  7. 07At the top of a file
  8. 08Which one to write
  9. 09The traps, collected

[] chapters · t top