Immutability: Why Const Isn't Enough
Const only prevents reassignment — it doesn't make your data immutable. Learn how to truly protect your data with Object.freeze, readonly types, and structural sharing.
Here’s a lie that JavaScript tells beginners: const makes things constant. It doesn’t. It prevents reassignment. The data itself is still wide open for mutation.
const user = { name: "Alice", role: "admin" };
user.role = "guest"; // No error. "Const" didn't protect anything.
const numbers = [1, 2, 3];
numbers.push(4); // Also fine. The array is "constant" but freely mutated.
This distinction matters because mutation is the root cause of an enormous class of bugs — stale state, race conditions, unexpected side effects, and the classic “who changed this object and when?”
Let’s look at how to actually achieve immutability in TypeScript and Python, and why it’s worth the effort.
Why Immutability Matters
Mutable state is shared state’s evil twin. The moment two parts of your codebase hold a reference to the same object, mutation becomes a landmine:
function applyDiscount(order: Order): Order {
order.total *= 0.9; // Mutates the original!
return order;
}
const original = { id: "123", total: 100 };
const discounted = applyDiscount(original);
console.log(original.total); // 90 — surprise! The original changed too.
With immutable data, this bug is impossible:
function applyDiscount(order: Order): Order {
return { ...order, total: order.total * 0.9 }; // New object, original untouched
}
const original = { id: "123", total: 100 };
const discounted = applyDiscount(original);
console.log(original.total); // 100 — safe
console.log(discounted.total); // 90 — correct
Level 1: TypeScript’s readonly
TypeScript’s readonly modifier catches mutations at compile time. It’s the cheapest win you can get:
interface User {
readonly id: string;
readonly name: string;
readonly email: string;
}
const user: User = { id: "1", name: "Alice", email: "[email protected]" };
user.name = "Bob"; // ✅ Compile error: Cannot assign to 'name' because it is a read-only property
For arrays and maps, use the Readonly variants:
function getActiveUserIds(users: readonly User[]): readonly string[] {
// users.push(newUser); // ✅ Compile error
// users.sort(); // ✅ Compile error — sort mutates in place!
return users.filter(u => u.isActive).map(u => u.id);
}
Deep Readonly
Vanilla readonly is shallow. Nested objects are still mutable:
interface Order {
readonly id: string;
readonly customer: {
name: string; // Still mutable!
};
}
const order: Order = { id: "1", customer: { name: "Alice" } };
order.customer.name = "Bob"; // No error — readonly didn't go deep
Fix it with a recursive type:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
interface Order {
id: string;
customer: {
name: string;
address: {
city: string;
};
};
}
const order: DeepReadonly<Order> = {
id: "1",
customer: { name: "Alice", address: { city: "NYC" } },
};
order.customer.address.city = "LA"; // ✅ Compile error — deep protection
Level 2: Runtime Immutability with Object.freeze
TypeScript’s readonly is erased at runtime. If you need actual enforcement (e.g., untrusted code, plugin systems), use Object.freeze:
const config = Object.freeze({
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
});
config.timeout = 10000; // Silently ignored (or throws in strict mode)
But Object.freeze is also shallow:
const config = Object.freeze({
api: {
url: "https://api.example.com",
headers: { Authorization: "Bearer token" },
},
});
config.api.url = "https://evil.com"; // This works! Freeze is shallow.
Deep freeze it yourself:
function deepFreeze<T extends Record<string, unknown>>(obj: T): Readonly<T> {
Object.freeze(obj);
for (const value of Object.values(obj)) {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
deepFreeze(value as Record<string, unknown>);
}
}
return obj;
}
const config = deepFreeze({
api: {
url: "https://api.example.com",
headers: { Authorization: "Bearer token" },
},
});
config.api.url = "https://evil.com"; // Now throws in strict mode
Level 3: Immutable Updates with Spread and Structuring
The spread operator is your workhorse for immutable updates:
// Updating a property
const updatedUser = { ...user, name: "Bob" };
// Adding to an array
const withNewItem = [...items, newItem];
// Removing from an array
const withoutItem = items.filter(item => item.id !== targetId);
// Updating a nested property (this gets ugly)
const updatedOrder = {
...order,
customer: {
...order.customer,
address: {
...order.customer.address,
city: "Los Angeles",
},
},
};
That nested update is painful. This is where libraries like Immer shine:
import { produce } from "immer";
const updatedOrder = produce(order, (draft) => {
draft.customer.address.city = "Los Angeles"; // Looks mutable, but produces a new object
});
// order.customer.address.city is still "NYC"
// updatedOrder.customer.address.city is "Los Angeles"
Immer uses structural sharing under the hood — unchanged parts of the tree keep their references, so equality checks (===) work correctly and memory usage stays reasonable.
Level 4: Structural Sharing
When you spread an object, you copy references — you don’t deep-clone everything. This is structural sharing, and it’s crucial for performance:
const original = { a: { x: 1 }, b: { y: 2 } };
const updated = { ...original, a: { x: 99 } };
updated.b === original.b; // true — same reference, not copied
updated.a === original.a; // false — new object
This matters in React (and similar frameworks) because it enables cheap change detection:
// React can skip re-rendering because the reference didn't change
if (prevProps.items === nextProps.items) {
return; // No update needed
}
If you deep-cloned everything, every comparison would fail, and every component would re-render every time.
Immutability in Python
Python has its own immutability story. Tuples and frozensets are immutable by default:
# Tuples: immutable sequences
coordinates = (10, 20)
# coordinates[0] = 30 # TypeError!
# Frozensets: immutable sets
allowed_roles = frozenset({"admin", "editor", "viewer"})
# allowed_roles.add("superadmin") # AttributeError!
For immutable data classes, use frozen=True:
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
id: str
name: str
email: str
user = User(id="1", name="Alice", email="[email protected]")
# user.name = "Bob" # FrozenInstanceError!
# To "update," create a new instance:
from dataclasses import replace
updated_user = replace(user, name="Bob")
Or with Pydantic (common in FastAPI projects):
from pydantic import BaseModel
class Config(BaseModel):
model_config = {"frozen": True}
api_url: str
timeout: int = 30
max_retries: int = 3
config = Config(api_url="https://api.example.com")
# config.timeout = 60 # ValidationError!
When to Use Immutability
Use it for:
- Configuration objects — should never change after initialization
- Function parameters — don’t mutate what you’re given
- State in UI frameworks (React, Vue) — enables efficient rendering
- Shared data between threads/async operations
- Domain events and value objects
Don’t overdo it when:
- Building up data in a tight loop (allocation overhead matters)
- Working with large binary data (buffers, streams)
- Performance-critical hot paths (profile first, optimize second)
A Practical Strategy
You don’t need to make everything immutable overnight. Start here:
- Mark all function parameters as
readonly— this alone catches most mutation bugs - Use
readonlyarrays by default —readonly string[]instead ofstring[] - Return new objects from functions — never mutate and return the input
- Use Immer for complex state updates — especially in Redux/state management
- Freeze configuration objects —
Object.freezeon startup
// A function that follows all these rules:
function processOrder(
order: DeepReadonly<Order>,
discount: number,
): Order {
return {
...order,
total: order.total * (1 - discount),
status: "processed",
processedAt: new Date(),
};
}
The original is protected. The return value is a clean new object. No surprises, no side effects, no bugs at 3 AM because something mutated something else three function calls ago.
That’s what real immutability gives you — not just const, but confidence.