mdashikjs/blog
All posts
Immutability in JavaScript: Beyond Object.freeze
Frontend

Immutability in JavaScript: Beyond Object.freeze

Frontend4 min

Immutability in JavaScript: Beyond Object.freeze

`Object.freeze` is shallow, silent on failure, and barely used in practice. The real story of immutability in JavaScript is about discipline, tooling, and picking the right primitive for the problem.

JavaScriptFunctional ProgrammingImmutability
Share:

Why Object.freeze does not carry the load

const config = Object.freeze({
  db: { host: 'localhost' },
});

config.db.host = 'hacked'; // works silently in non-strict mode

Freeze is shallow. Nested objects are still mutable. Deep freeze exists but is not free and does not play well with TypeScript types. For most codebases, freeze is not the answer.

The discipline answer

Treat data as immutable by convention. Never mutate function arguments. Always return new objects from reducers and transforms. Use const everywhere — not because it prevents mutation (it doesn't), but because it signals intent.

// bad
function addItem(cart, item) {
  cart.items.push(item);
  return cart;
}

// good
function addItem(cart, item) {
  return { ...cart, items: [...cart.items, item] };
}

This reads clearly. Bugs from shared mutable state disappear. But it gets verbose for deep structures:

return {
  ...state,
  user: {
    ...state.user,
    preferences: {
      ...state.user.preferences,
      theme: 'dark',
    },
  },
};

Enter Immer

Immer lets you write mutating code against a draft and hands you an immutable result:

import { produce } from 'immer';

const next = produce(state, (draft) => {
  draft.user.preferences.theme = 'dark';
});

Under the hood it uses structural sharing — unchanged branches are reused, not copied. The new state is immutable, the old state is untouched, and you wrote three lines instead of ten.

Redux Toolkit uses Immer by default for exactly this reason. Zustand supports it as an opt-in middleware. If you are hand-rolling nested spreads, switch.

TypeScript readonly as a seatbelt

Types can enforce immutability at the boundary:

type Config = {
  readonly db: {
    readonly host: string;
    readonly port: number;
  };
};

Readonly<T> and ReadonlyArray<T> help but do not recurse. type-fest's ReadonlyDeep does. Type-level immutability does not stop JSON.parse from producing a mutable object at runtime, but it stops your code from mutating it by accident.

When mutability is the right call

Performance-critical paths where you own the data, short-lived, and never shared: mutate freely. Building up a 100k-item array? Push, do not spread. A tight inner loop? i++ is fine. Immutability is about avoiding shared mutable state, not about banning assignment.

The working rule

Data that crosses a boundary — function argument, state update, API response — is immutable. Inside a function you own, mutate if it is clearer. Use Immer for nested updates. Use readonly types for public APIs. Skip Object.freeze entirely except in tests where you want a hard guardrail against accidental mutation.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.