Modern JavaScript gives you three ways to declare a variable — const, let, and var — and only one of them is the right default. This post is the practical guide a working developer needs in 2026: a modern-first comparison, the rules of scope, the truth about hoisting and the temporal dead zone, naming conventions, and the small set of cases where the legacy var keyword still appears in the wild.
What “variable” actually means
A variable is a named binding to a value. Once you declare it, the JavaScript engine reserves a slot in memory and lets you refer to that slot by name. You don’t manage the memory yourself — the engine’s garbage collector reclaims it when the variable is no longer reachable.
JavaScript is dynamically typed: the same binding can hold a string at one moment and an array the next. The type belongs to the value, not the variable.
1let x = 42; // x holds a number
2x = 'forty-two'; // now x holds a string — perfectly legal
3x = [4, 2]; // now an array
Note
If you want static type checking on top of JavaScript, use TypeScript. It compiles to plain JavaScript and erases types at build time.
The modern default: const, then let, never var
Since ES6 (2015) the recommended rule is simple:
- Use
constby default. It declares a binding that cannot be reassigned. - Use
letonly when you need to reassign. Loop counters, accumulators, conditional values. - Never use
varin new code. It exists only because the web cannot break old pages.
1const MAX_RETRIES = 3; // never reassigned
2let attempts = 0; // will be incremented
3attempts += 1; // OK
4// MAX_RETRIES = 5; // TypeError: Assignment to constant variable
const does not mean immutable
const prevents reassignment of the binding. It does not freeze the value. Objects and arrays declared with const can still be mutated.
1const user = { name: 'Aisha', age: 30 };
2user.age = 31; // OK — mutating the object
3user.city = 'Karachi'; // OK — adding a property
4// user = { name: 'Bilal' }; // TypeError — reassigning the binding
If you need a value you cannot mutate either, use Object.freeze:
1const frozen = Object.freeze({ name: 'Aisha' });
2frozen.name = 'Bilal'; // silently ignored in sloppy mode, throws in strict mode
Side-by-side: var, let, const
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisted | Yes (initialised to undefined) | Yes (in TDZ) | Yes (in TDZ) |
| Redeclaration in same scope | Allowed | Error | Error |
| Reassignment | Allowed | Allowed | Error |
| Attaches to global object | Yes (when declared at top level in a script) | No | No |
| Introduced | ES1 (1997) | ES6 (2015) | ES6 (2015) |
The single most important row is scope. var is scoped to the nearest enclosing function (or to the global scope). let and const are scoped to the nearest enclosing { ... } block, which is what every other mainstream language does.
Scope, the right way
A variable’s scope is the region of the program where its name is bound. JavaScript has three kinds of scope:
- Global scope — declared outside any function or block.
- Function scope — declared inside a function (this is what
varalways uses). - Block scope — declared inside
{ ... }, includingif,for,while, and bare blocks (this is whatlet/constuse).
Block scope in action
1function priceFor(quantity) {
2 if (quantity > 10) {
3 const discount = 0.1; // block-scoped to the if
4 return quantity * 9 * (1 - discount);
5 }
6 // discount is not visible here — ReferenceError
7 return quantity * 10;
8}
The same code with var leaks discount to the entire function — useful at the time, surprising ever since:
1function priceFor(quantity) {
2 if (quantity > 10) {
3 var discount = 0.1; // function-scoped — visible below
4 }
5 console.log(discount); // undefined when quantity <= 10
6 return quantity * 10;
7}
The classic for loop trap
The most cited reason to prefer let over var:
1// With var: all callbacks share the same i (3)
2for (var i = 0; i < 3; i++) {
3 setTimeout(() => console.log(i), 0); // 3, 3, 3
4}
5
6// With let: each iteration gets its own i
7for (let i = 0; i < 3; i++) {
8 setTimeout(() => console.log(i), 0); // 0, 1, 2
9}
Before ES6, the workaround was an IIFE (Immediately Invoked Function Expression) per iteration. let solves it with no boilerplate.
Scope chain
When you reference a name, the engine looks it up in the current scope, then the enclosing scope, then the next one out, all the way to the global scope. The first match wins.
1const planet = 'Earth'; // global
2
3function outer() {
4 const continent = 'Asia'; // outer scope
5
6 function inner() {
7 const country = 'Pakistan'; // inner scope
8 console.log(country, continent, planet); // all three resolve via the chain
9 }
10
11 inner();
12}
That chain is also why closures work — see Functions in depth — closures and this.
Visualising scope
Hoisting and the Temporal Dead Zone
Hoisting is the JavaScript engine’s habit of processing all declarations in a scope before executing any code in it. The practical effect depends on which keyword you used.
var hoisting
var declarations are hoisted and initialised to undefined. So this runs without throwing:
1console.log(name); // undefined — not a ReferenceError
2var name = 'Aisha';
3console.log(name); // 'Aisha'
The engine treats it as if you had written:
1var name; // declaration hoisted, initialised to undefined
2console.log(name); // undefined
3name = 'Aisha'; // assignment stays in place
4console.log(name); // 'Aisha'
let and const hoisting — the Temporal Dead Zone (TDZ)
let and const are also hoisted, but they are not initialised. The window between the start of the block and the line where the declaration appears is the Temporal Dead Zone. Touching the binding inside the TDZ throws.
1console.log(name); // ReferenceError: Cannot access 'name' before initialization
2let name = 'Aisha';
Important
The TDZ is a feature, not a bug. It catches use-before-declare mistakes that
varsilently swallowed for two decades.
Functions are hoisted differently
Function declarations are fully hoisted — both the name and the body — which is why this works:
1greet(); // 'Hello'
2
3function greet() {
4 console.log('Hello');
5}
Function expressions assigned to var, let, or const follow the rules of their declaration keyword:
1greet(); // TypeError: greet is not a function (with var)
2var greet = function () { console.log('Hello'); };
3
4greet(); // ReferenceError (with let or const — TDZ)
5const greet = function () { console.log('Hello'); };
More on these distinctions in Function basics and the Advanced reference.
Strict mode and the death of implicit globals
In pre-ES5 JavaScript, assigning to an undeclared name silently created a global variable:
1function setup() {
2 total = 100; // no var/let/const — leaks to the global object
3}
4setup();
5console.log(total); // 100
ES5 introduced 'use strict', which turns this into a ReferenceError. ES6 modules (import/export) and ES6 class bodies are automatically strict — you cannot opt out. Modern code is therefore strict by default.
1'use strict';
2
3function setup() {
4 total = 100; // ReferenceError: total is not defined
5}
If you are using ES modules (the recommended default — see JavaScript modules), strict mode is already on.
Naming variables
JavaScript identifiers must:
- Start with a letter,
_, or$. The starting character cannot be a digit. - Continue with letters, digits,
_, or$. Unicode letters are allowed but stick to ASCII for portability. - Not be a reserved word (
return,class,import,this, etc.).
Conventions
| Kind | Convention | Example |
|---|---|---|
| Regular variables and functions | camelCase | userName, calculateTotal |
| Classes and constructors | PascalCase | UserAccount, HttpClient |
| Module-level constants (true constants) | UPPER_SNAKE_CASE | MAX_RETRIES, API_BASE_URL |
| Private fields (ES2022) | #name | #secret, #counter |
| Library-private (older convention) | _name | _internalCache |
1const MAX_RETRIES = 3;
2const apiBaseUrl = 'https://api.example.com';
3
4class UserAccount {
5 #password; // ES2022 private field
6 constructor(name, password) {
7 this.name = name;
8 this.#password = password;
9 }
10}
Pick descriptive names
1// Don't
2const d = new Date() - userStart;
3
4// Do
5const elapsedMs = new Date() - userStart;
Names are the most-read, least-rewritten part of any codebase. Spend time on them.
Destructuring: declaring many variables at once
ES6 added destructuring, which lets you declare and assign several variables from an object or array in one line:
1const user = { name: 'Aisha', age: 30, city: 'Karachi' };
2
3// Object destructuring
4const { name, city } = user;
5console.log(name, city); // 'Aisha' 'Karachi'
6
7// With renaming and defaults
8const { name: fullName, country = 'Pakistan' } = user;
9
10// Array destructuring
11const [first, second] = ['a', 'b', 'c'];
12console.log(first, second); // 'a' 'b'
13
14// Swap two variables — no temp needed
15let a = 1, b = 2;
16[a, b] = [b, a];
17console.log(a, b); // 2 1
Destructuring is now the dominant way to pull values out of function arguments, API responses, and module imports.
Checking for undefined and null
A variable that exists but has not been assigned is undefined. A variable explicitly set to “no value” is null. The two are similar but not equal under strict comparison.
1let x;
2console.log(x); // undefined
3console.log(x === undefined); // true
4console.log(x === null); // false
5console.log(x == null); // true — loose equality treats null and undefined as equal
The ?? (nullish coalescing) operator, added in ES2020, is the modern way to provide a fallback only when a value is null or undefined:
1const port = config.port ?? 3000; // fallback only if port is null/undefined
2const empty = '' ?? 'default'; // '' — empty string is kept
3const zero = 0 ?? 42; // 0 — zero is kept
Compare that to the older || idiom, which falls back on any falsy value:
1const port = config.port || 3000; // falls back if port is 0 too — usually a bug
See JavaScript conditionals for the full truthiness story.
When does var still appear?
You will see var in:
- Pre-2015 code, transpiled output, and old StackOverflow answers.
- Single-file
<script>snippets in tutorials. - Polyfills targeting ES5 environments.
- Code generated by some bundlers for legacy browser targets.
In greenfield code today, there is no reason to type var. Linters such as ESLint default to flagging it.
Public, private, and the IIFE pattern (legacy)
Before ES2022 added native private class fields (#field), the only way to hide state was a closure. The classic pattern is the IIFE:
1const counter = (function () {
2 let count = 0; // private, captured by closure
3 return {
4 increment() { count += 1; },
5 value() { return count; },
6 };
7})();
8
9counter.increment();
10counter.increment();
11console.log(counter.value()); // 2
12console.log(counter.count); // undefined — count is not exposed
The modern equivalent uses a class with private fields:
1class Counter {
2 #count = 0;
3 increment() { this.#count += 1; }
4 get value() { return this.#count; }
5}
6
7const c = new Counter();
8c.increment();
9console.log(c.value); // 1
10console.log(c.count); // undefined
11console.log(c.#count); // SyntaxError — private fields are not accessible from outside
See ES6 classes for the full story.
Summary
- Use
constby default;letwhen you must reassign; nevervarin new code. constprevents reassignment, not mutation. UseObject.freezefor shallow immutability.letandconstare block-scoped;varis function-scoped.- All declarations are hoisted, but
letandconstsit in a Temporal Dead Zone until their declaration line executes — accessing them early throws. - Modern JavaScript (ES modules, classes) is automatically strict, so implicit globals are gone.
- Use destructuring to declare many variables at once.
- Use
??for null/undefined fallbacks instead of||.
For deeper material on what variables resolve against at runtime, see Advanced reference: execution context & hoisting and this and context.
Test your understanding
Further reading
- MDN: var, let, and const — the canonical reference.1
- TC39 ECMA-262: Declarations and the Variable Statement — the formal spec.2
- JavaScript Validator — identifier checker — handy when checking whether a name is legal.
Mozilla Developer Network. let. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let ↩︎
Ecma International (2024). ECMA-262, 15th edition. https://tc39.es/ecma262/ ↩︎










