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 variableA 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.
var | let | const | |
|---|---|---|---|
| Scope | nearest function | nearest block | nearest block |
| Redeclare in the same scope | allowed, silently | SyntaxError | SyntaxError |
| Reassign | allowed | allowed | TypeError |
| Usable before its line | yes, value is undefined | no, ReferenceError | no, ReferenceError |
| Creates a property on the global object | yes, in a classic script | no | no |
| Needs an initialiser | no | no | yes |
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
letcreates 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 tovarand you getinnertwice, because there is only one binding. constguarantees which of these?- The binding can never be reassigned — Only reassignment is blocked.
const user = {}still lets you writeuser.name = "Ada". If you want the contents locked,Object.freezegets 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.letandconstraise aSyntaxErrorbefore the file even runs, which is the behaviour you want.
Common mistakes
- Reading
constas immutable. It only blocks reassignment of the name. - Declaring a
varinside anifor aforand being surprised that it is visible afterwards. - Reaching for
Object.freezeand forgetting it is shallow, so nested objects stay writable.
Takeaways
constlocks the label, not the box. Object contents remain mutable.letandconstare block scoped;varis function scoped and leaks out of blocks.- Redeclaring with
varis silent; withletorconstit is aSyntaxError. - Default to
const, useletwhen you truly reassign, and leavevarin the past.