JavaScript Const
const is the default declaration keyword in modern JavaScript. It’s block-scoped like let, but it locks the binding — once you set it, you can’t reassign it. That single guarantee makes code dramatically easier to read: when you see const, you know the name will always point at the same value.
The Basics
const PI = 3.14159;
PI = 3.15; // ❌ TypeError: Assignment to constant variable
You must initialize at declaration:
const x; // ❌ SyntaxError: Missing initializer in const declaration
That’s the deal: declare-and-set in one step, never reassign.
What const Actually Locks
This is the most misunderstood part of const. It locks the binding (the name → value link), not the value itself. If the value is an object or array, you can still mutate its contents.
const user = { name: "Ada" };
user.name = "Linus"; // ✅ mutating the object — fine
user.role = "admin"; // ✅ adding a property — fine
console.log(user); // { name: "Linus", role: "admin" }
user = { name: "Grace" }; // ❌ TypeError — can't rebind
const user ─────► ┌────────────────┐
│ { name: "Ada" }│ ← contents can change
└────────────────┘
▲
│
this arrow is locked
you can't point user at a different object
Same with arrays:
const tags = ["red", "blue"];
tags.push("green"); // ✅ mutates the array — fine
tags[0] = "orange"; // ✅ mutates an index — fine
tags.length = 0; // ✅ clears the array — fine
tags = []; // ❌ TypeError — can't reassign
Note: Don’t be fooled by the keyword. const is about reference stability, not value immutability.
True Immutability with Object.freeze()
If you genuinely want a value that cannot be mutated, freeze it:
const config = Object.freeze({
apiUrl: "https://api.example.com",
timeout: 5000,
});
config.timeout = 9000; // ❌ silently ignored in non-strict mode
// ❌ throws TypeError in strict mode / modules
console.log(config.timeout); // 5000
Object.freeze() is shallow — nested objects are not frozen:
const settings = Object.freeze({
theme: { dark: true },
});
settings.theme = "light"; // ❌ blocked (top-level)
settings.theme.dark = false; // ✅ works — nested object isn't frozen
console.log(settings.theme.dark); // false
For deep freezing you need a helper:
function deepFreeze(obj) {
for (const key of Object.keys(obj)) {
if (typeof obj[key] === "object" && obj[key] !== null) {
deepFreeze(obj[key]);
}
}
return Object.freeze(obj);
}
| Mechanism | Locks Binding | Locks Top-level Props | Locks Nested |
|---|---|---|---|
const |
✅ | ❌ | ❌ |
const + Object.freeze |
✅ | ✅ | ❌ |
const + deepFreeze |
✅ | ✅ | ✅ |
const in Loops
In for…of and for…in, each iteration introduces a new binding — so const is correct:
const items = ["a", "b", "c"];
for (const item of items) { // ✅ const — new binding each iteration
console.log(item);
}
for (const key of Object.keys({ a: 1, b: 2 })) { // ✅ const works here too
console.log(key);
}
But a classic for counter reassigns the same i, so it needs let:
for (const i = 0; i < 3; i++) { // ❌ TypeError on i++
}
for (let i = 0; i < 3; i++) { // ✅ correct
}
Block Scope — Same as let
const is block-scoped exactly like let:
if (true) {
const message = "hidden";
}
console.log(message); // ❌ ReferenceError
And it has the same temporal dead zone:
console.log(name); // ❌ ReferenceError — TDZ
const name = "Ada";
When to Use const (Almost Always)
Default to const everywhere. You’ll be right ~90% of the time.
// ✅ Config
const API_BASE = "https://api.example.com";
const MAX_RETRIES = 3;
// ✅ Imported modules
const fs = require("fs");
import { useState } from "react";
// ✅ Function definitions
const greet = (name) => `Hi ${name}`;
// ✅ Derived values
const total = items.reduce((sum, i) => sum + i.price, 0);
// ✅ DOM references
const button = document.querySelector("#submit");
// ✅ Objects and arrays you'll mutate
const cart = [];
cart.push(item); // works, because we're not reassigning cart
Switch to let only when you see a future line like x = something:
let attempt = 0; // will increment in the loop
while (attempt < MAX) {
// ...
attempt += 1; // ← reassignment, so let is correct
}
const vs let vs var — Quick Reference
const |
let |
var |
|
|---|---|---|---|
| Scope | Block | Block | Function |
| Reassignment | ❌ | ✅ | ✅ |
| Re-declaration in same scope | ❌ | ❌ | ✅ |
| Must initialize | ✅ | ❌ | ❌ |
| Hoisted | ✅ (TDZ) | ✅ (TDZ) | ✅ (undefined) |
Pollutes window at top level |
❌ | ❌ | ✅ |
| Use in new code | ✅ default | ✅ when needed | ❌ never |
Naming Conventions for Constants
Two patterns coexist:
// 1. Module-level "compile-time" constants — UPPER_SNAKE_CASE
const MAX_RETRIES = 3;
const API_BASE = "https://api.example.com";
// 2. Local / derived const values — camelCase (same as any variable)
const user = await fetchUser(id);
const isAdmin = user.role === "admin";
The rule of thumb: UPPER_SNAKE_CASE for true magic numbers and config you’d treat as enum-like; camelCase for runtime values that happen to be assigned once.
Real-World Example
// Top-of-module constants
const API_BASE = "https://api.example.com";
const PAGE_SIZE = 50;
const RETRY_BACKOFF_MS = Object.freeze([100, 250, 500, 1000]);
async function loadOrders(userId) {
const url = `${API_BASE}/users/${userId}/orders?limit=${PAGE_SIZE}`;
const orders = []; // const — we mutate, don't rebind
for (let attempt = 0; attempt < RETRY_BACKOFF_MS.length; attempt++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
orders.push(...data.orders); // mutate the const array — OK
return orders;
} catch (err) {
const wait = RETRY_BACKOFF_MS[attempt];
await new Promise(r => setTimeout(r, wait));
}
}
throw new Error("Failed after retries");
}
Notice: const everywhere except attempt, which truly counts up.
Common Misconceptions
| Misconception | Reality |
|---|---|
“const makes the value immutable” |
❌ It only freezes the binding |
“Arrays declared with const are read-only” |
❌ You can push, pop, splice, reassign indices |
“const is slower than let” |
❌ Same performance; engines optimise both identically |
“I should always use let because it’s flexible” |
❌ Constraint helps readers; use const and only relax to let when needed |
“const can’t have its value depend on a runtime expression” |
❌ It can — const user = await fetchUser() is fine |
Best Practices Checklist
- ✅ Default to
const. Always. - ✅ Use
Object.freeze()(or a deep freeze) when you need real immutability - ✅ Use
UPPER_SNAKE_CASEfor module-level magic constants - ✅ Let
prefer-const(ESLint rule) catchlets that never get reassigned - ❌ Don’t reach for
letuntil you have a concrete reassignment in mind - ❌ Don’t assume
constprevents object mutation - ❌ Don’t
Object.freezeeverything reflexively — it has runtime cost; reserve it for shared, exported, or genuinely-immutable values
Tip: A useful mental model — const is to JS what final is to Java or readonly is to C#. It’s about intent: “this name points at this thing and that’s not going to change.”
What’s Next?
You’ve nailed variable declarations. Now we’ll look at what kinds of values those variables can hold — strings, numbers, booleans, objects, and the seven primitive types JavaScript ships with.