JavaScript Variables
A variable is a named container that stores a value in memory so your program can read it, change it, and reuse it without retyping the value everywhere. Every piece of data your code works with — a user’s name, a running total, a list of products — needs to live in a variable so it can be referred to by a meaningful name. JavaScript gives you three keywords for declaring variables: var, let, and const. Knowing exactly how each one behaves, especially around scope and timing, is one of the most important foundations for writing reliable JavaScript.
Overview / How Variables Work
Declaring a variable happens in two logical steps, even when you write them on one line: declaration, where the JavaScript engine reserves a name in a particular scope, and initialization, where you give it a starting value. let age; declares age without a value (it becomes undefined), while let age = 30; declares and initializes it in one step.
JavaScript has three ways to declare a variable:
var— the original keyword from the earliest versions of JavaScript. It is function-scoped (or globally scoped if declared outside any function), and it can be redeclared and reassigned freely.let— introduced in ES6 (2015). It is block-scoped, meaning it only exists inside the nearest pair of curly braces: anifblock, aforloop, a function body, or a bare block. It can be reassigned but not redeclared in the same scope.const— also introduced in ES6, also block-scoped. It must be initialized when declared, and its binding can never be reassigned afterward.
Scope determines where in your code a variable is visible. JavaScript has global scope (declared outside any function or block), function scope (visible anywhere inside the function that declares it), and, since ES6, block scope (visible only inside the braces where it was declared). This is the biggest practical difference between var and let/const: a var declared inside an if block leaks out and stays visible for the rest of the enclosing function, while a let or const does not.
Internally, before your code actually runs, the JavaScript engine performs a creation phase where it scans the current scope and registers every declared variable in memory ahead of time. This is called hoisting. var declarations are hoisted and initialized to undefined immediately, which is why you can reference a var before its declaration line without an error — you just get undefined back. let and const are hoisted too, but they are not initialized. They sit in a temporal dead zone (TDZ) from the start of their scope until the line where they are actually declared. Accessing them during the TDZ throws a ReferenceError. This is a deliberate safety feature: it stops you from accidentally using a variable before it has a sensible value.
Finally, it matters what kind of value a variable holds. Primitive values (numbers, strings, booleans, null, undefined, symbol, bigint) are copied by value — assigning one variable to another copies the value itself. Objects and arrays are stored by reference — the variable holds a pointer to data in memory, not the data itself. This is why const lets you mutate the contents of an array or object freely (you are not changing the binding, only what it points to) but never lets you point the variable at a brand-new array or object.
Syntax
let variableName = value;
const CONSTANT_NAME = value;
var legacyName = value;
let a, b, c; // multiple declarations
let x = 1, y = 2; // multiple with initializers
| Keyword | Scope | Reassignable | Redeclarable | Hoisted |
|---|---|---|---|---|
var |
Function / global | Yes | Yes | Yes, initialized to undefined |
let |
Block | Yes | No | Yes, stays in the TDZ until declared |
const |
Block | No | No | Yes, stays in the TDZ until declared |
Naming Rules
- Names may contain letters, digits,
$, and_, but cannot start with a digit. - Names are case-sensitive —
totalandTotalare different variables. - Reserved words such as
let,class, andreturncannot be used as variable names. - By convention, use
camelCasefor ordinary variables and functions, andUPPER_SNAKE_CASEfor constants that represent fixed, unchanging values.
Examples
Example 1: Declaring and reassigning
let score = 10;
score = score + 5;
const username = 'nova92';
console.log(`${username} has a score of ${score}`);
Output:
nova92 has a score of 15
Here score is declared with let because its value needs to change, so reassigning it with score = score + 5 is legal. username is declared with const because it never needs to change, and a template literal is used to combine both values into one readable string.
Example 2: Function scope vs. block scope
function demoScope() {
if (true) {
var functionScoped = 'I leak out of the block';
let blockScoped = 'I stay inside the block';
console.log(functionScoped);
console.log(blockScoped);
}
console.log(functionScoped);
try {
console.log(blockScoped);
} catch (error) {
console.log(error.message);
}
}
demoScope();
Output:
I leak out of the block
I stay inside the block
I leak out of the block
blockScoped is not defined
Both variables are declared inside the if block, but only functionScoped (declared with var) is still readable once execution leaves the block, because var only respects function boundaries. blockScoped (declared with let) ceases to exist outside its block, so accessing it throws a ReferenceError, caught here and logged as a plain message.
Example 3: const with arrays and objects
const cart = ['apple', 'banana'];
cart.push('cherry');
console.log(cart);
const totalItems = cart.length;
console.log(`Cart has ${totalItems} items: ${cart.join(', ')}`);
Output:
[ 'apple', 'banana', 'cherry' ]
Cart has 3 items: apple, banana, cherry
cart is declared with const, yet cart.push('cherry') works fine. That’s because push mutates the array that cart points to; it doesn’t reassign the cart variable itself. const only locks the binding — the fact that the name cart always refers to that one array in memory — not the contents of what it refers to.
Under the Hood: Hoisting and the Temporal Dead Zone
console.log(typeof hoistedVar); // undefined due to hoisting
var hoistedVar = 'I am hoisted';
try {
console.log(hoistedLet);
} catch (error) {
console.log(error.message);
}
let hoistedLet = 'I am in the temporal dead zone';
Output:
undefined
Cannot access 'hoistedLet' before initialization
Before executing this file line by line, the engine scans the scope and registers both hoistedVar and hoistedLet in memory. hoistedVar is immediately set to undefined, so reading it early gives 'undefined' from typeof rather than an error. hoistedLet, however, is registered but left uninitialized and marked as being in the temporal dead zone. Trying to read it before its declaration line executes throws a ReferenceError with a message that explicitly calls out the TDZ. This is why relying on hoisting with var is dangerous — the code runs but silently gives you undefined — while let/const fail loudly and immediately, which is easier to debug.
Common Mistakes
Mistake 1: Using var in a loop that creates functions
A classic bug happens when a loop declared with var is used to create functions (callbacks, event handlers, timers) that reference the loop variable. Because var is function-scoped, all the created functions share the exact same variable — not a fresh copy per iteration.
const timers = [];
for (var i = 0; i < 3; i++) {
timers.push(() => console.log(i));
}
timers.forEach(fn => fn());
Output:
3
3
3
By the time any of the functions actually run, the loop has already finished and i is 3 for all of them. Switching to let fixes it, because let creates a brand-new binding of i for each iteration of the loop:
const timers = [];
for (let i = 0; i < 3; i++) {
timers.push(() => console.log(i));
}
timers.forEach(fn => fn());
Output:
0
1
2
Mistake 2: Assuming const makes a value immutable
A very common misconception is that const freezes a value. It doesn’t — it only prevents the variable name from being pointed at something new. Writing const settings = { theme: 'dark' }; settings = {}; throws TypeError: Assignment to constant variable., because that reassigns the binding. But mutating a property, like settings.theme = 'light';, works fine, because the binding itself never changes.
const settings = { theme: 'dark', fontSize: 14 };
settings.theme = 'light';
console.log(settings);
const frozenSettings = Object.freeze({ theme: 'dark' });
frozenSettings.theme = 'light';
console.log(frozenSettings.theme);
Output:
{ theme: 'light', fontSize: 14 }
dark
If you actually need to prevent an object’s contents from being changed, const alone is not enough — use Object.freeze(). Note that in a normal (non-strict-mode) script, attempting to write to a frozen object’s property fails silently instead of throwing, which is why frozenSettings.theme is still 'dark' afterward.
Best Practices
- Default to
constfor every variable; only switch toletwhen you know the value must change later. - Avoid
varin modern code — its function scoping and hoisting-to-undefinedbehavior cause bugs that block scoping prevents. - Declare variables as close as possible to where they’re first used, rather than all at the top of a function.
- Give variables descriptive, intention-revealing names (
userCount, notucorx). - Never rely on hoisting — always declare a variable before you use it, even though
vartechnically allows otherwise. - Use
UPPER_SNAKE_CASEfor values that are conceptually constants across your whole program, like configuration flags or fixed limits. - Remember that
constprotects the binding, not the value — useObject.freeze()if you need true immutability for an object.
Practice Exercises
- Write a function that declares a
letcounter variable, increments it three times inside a loop, and logs its final value. Then try rewriting the loop variable asconstand explain in a comment why it fails. - Predict, then verify, what the following logs: declare
varx inside anifblock, try to read it right after the block outside the function, and also try reading aletvariable declared inside that same block from outside it. - Create a
constobject representing a product (name,price,inStock). Write code that updates itspriceproperty directly, then write code that attempts to reassign the whole variable to a new object and observe the error.
Summary
- Variables store values under a name using
var,let, orconst. varis function-scoped and hoisted toundefined;letandconstare block-scoped and stay in the temporal dead zone until declared.letcan be reassigned;constcannot be reassigned but its object/array contents can still be mutated.- Accessing a
letorconstbefore its declaration throws aReferenceError; accessing avarearly just givesundefined. - Prefer
constby default, useletwhen reassignment is needed, and avoidvarin modern code.
