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:

  1. Mark all function parameters as readonly — this alone catches most mutation bugs
  2. Use readonly arrays by defaultreadonly string[] instead of string[]
  3. Return new objects from functions — never mutate and return the input
  4. Use Immer for complex state updates — especially in Redux/state management
  5. Freeze configuration objectsObject.freeze on 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.