var, let and const

Mental model: const locks the label, not the box.

Level: beginner · about 12 minutes

const config = { theme: 'dark' };

config.theme = 'light';    // allowed: same object, new contents
config.debug = true;       // allowed: same object, new property
console.log(config);       // { theme: 'light', debug: true }

// config = {};            // TypeError: Assignment to constant variable

A const object being changed. Nothing here is a mistake.

A declaration does two jobs: it creates a binding (a name in a scope) and it decides where that name is visible. The three keywords answer those two questions differently, and only one of them is a sensible default.

varletconst
Scopenearest functionnearest blocknearest block
Redeclare in the same scopeallowed, silentlySyntaxErrorSyntaxError
ReassignallowedallowedTypeError
Usable before its lineyes, value is undefinedno, ReferenceErrorno, ReferenceError
Creates a property on the global objectyes, in a classic scriptnono
Needs an initialisernonoyes

Block scope versus function scope

var ignores the block

function pick(flag) {
  if (flag) {
    var chosen = 'yes';
  }
  return chosen; // 'yes' or undefined
}

let respects the block

function pick(flag) {
  if (flag) {
    let chosen = 'yes';
  }
  return chosen; // ReferenceError
}

A block is any pair of curly braces: if, for, while, try, or a bare { }. var only sees function boundaries, which is why a variable declared inside an if leaks out of it.

for (var i = 0; i < 3; i++) { /* ... */ }
console.log('var i after the loop:', i); // 3, the loop variable escaped

for (let j = 0; j < 3; j++) { /* ... */ }
try {
  console.log(j);
} catch (err) {
  console.log('let j after the loop:', err.name); // ReferenceError
}

The classic loop symptom of function scope.

Redeclaration and shadowing

var total = 1;
var total = 2;              // legal, and how a typo becomes a bug
console.log(total);         // 2

let count = 1;
// let count = 2;           // SyntaxError: count has already been declared

{
  let total = 'shadowed';   // a new binding in an inner block
  console.log(total);       // 'shadowed'
}
console.log(total);         // 2

Shadowing is legal and sometimes useful, for example a narrow loop variable. It becomes a problem when two different things share one name and you spend twenty minutes debugging the wrong one.

Global pollution

// In a classic <script>, top level var and function declarations
// attach themselves to the global object:
var appName = 'lab';
console.log(globalThis.appName); // 'lab'

let version = 1;
console.log(globalThis.version); // undefined

// Inside a module (type="module"), neither one is global. Modules are
// scoped and strict by default, which is one reason to always use them.
const list = [1, 2];
list.push(3);
console.log(list.length);
list = [];

push mutates the array, which const never prevented, so the length is 3. The last line tries to point the label at a different array, and that is exactly what const forbids, so it throws TypeError: Assignment to constant variable.

Try it yourself

const versus Object.freeze

const settings = { theme: 'dark', nested: { size: 10 } };
settings.theme = 'light';        // const does not stop this
console.log(settings.theme);     // 'light'

Object.freeze(settings);
settings.theme = 'dark';         // ignored (throws in strict mode)
console.log(settings.theme);     // 'light'

settings.nested.size = 99;       // freeze is shallow
console.log(settings.nested.size); // 99

Add a nested object to settings and try to change it after freezing. Why does that still work?

Exercises

Fix the vote tally

This tally should count how many times each vote appears and return an object like { a: 2, b: 1 }. The var declaration inside the loop resets the counts on every pass. Rewrite it with block-scoped declarations so the counts survive the loop.

Check yourself

What does this log?
'inner' then 'outer' — The inner let creates a second, separate binding that shadows the outer one for the length of the block. When the block ends, the outer binding is visible again, unchanged. Swap both to var and you get inner twice, because there is only one binding.
const guarantees which of these?
The binding can never be reassigned — Only reassignment is blocked. const user = {} still lets you write user.name = "Ada". If you want the contents locked, Object.freeze gets you one shallow level, and even that is usually better solved by not mutating in the first place.
Which declaration can be repeated in the same scope without any error?
var — var total = 1; var total = 2; is legal and silent, so a duplicated name from a copy-paste never gets reported. let and const raise a SyntaxError before the file even runs, which is the behaviour you want.

Common mistakes

  • Reading const as immutable. It only blocks reassignment of the name.
  • Declaring a var inside an if or a for and being surprised that it is visible afterwards.
  • Reaching for Object.freeze and forgetting it is shallow, so nested objects stay writable.

Takeaways

  • const locks the label, not the box. Object contents remain mutable.
  • let and const are block scoped; var is function scoped and leaks out of blocks.
  • Redeclaring with var is silent; with let or const it is a SyntaxError.
  • Default to const, use let when you truly reassign, and leave var in the past.