Why and how to use Object.freeze to prevent mutations, what its limits are, and when to prefer alternatives like Object.seal, immutability patterns, or TypeScript.

Object.freeze to prevent mutations, what its limits are, and when to prefer alternatives like Object.seal, immutability patterns, or TypeScript.Introduction
Have you ever debugged a bug that came from silent mutations?
const config = { retries: 3 };
config.retries = 99; // Oops, changed global config!
In big apps — React, Redux, Node services — mutations like this can wreak havoc.
Enter Object.freeze: a built-in tool that makes objects immutable at runtime.
But here’s the catch: Object.freeze isn’t a silver bullet. It has quirks, limits, and trade-offs. This post explains how it works, when to use it, and how to avoid its gotchas.
1. What Object.freeze Does
const obj = { a: 1 };
Object.freeze(obj);
obj.a = 2; // ❌ silently ignored in non-strict mode
delete obj.a; // ❌ fails
obj.b = 3; // ❌ fails
console.log(obj); // { a: 1 }
- Properties cannot be added, removed, or reassigned.
- Property descriptors (
writable,configurable) are set tofalse. - Works shallowly — nested objects aren’t frozen.
2. Freezing vs. Other Object Controls

👉 Use freeze when you want read-only objects.
👉 Use seal when you want to allow changes to existing values but no new keys.
3. Deep Freeze: Handling Nested Objects
Object.freeze doesn’t recurse by default:
const settings = { ui: { theme: "dark" } };
Object.freeze(settings);
settings.ui.theme = "light"; // ✅ still works! (ui not frozen)
To fully lock it down, implement deepFreeze:
function deepFreeze(obj) {
Object.getOwnPropertyNames(obj).forEach((prop) => {
const value = obj[prop];
if (value && typeof value === "object") {
deepFreeze(value);
}
});
return Object.freeze(obj);
}
const frozen = deepFreeze({ a: { b: 2 } });
frozen.a.b = 99; // ❌ fails
4. Strict Mode Behavior
In non-strict mode: writes fail silently.
In strict mode: they throw TypeError.
"use strict";
const cfg = Object.freeze({ retries: 3 });
cfg.retries = 10; // ❌ TypeError: Cannot assign to read only property
👉 Strict mode makes bugs more visible — prefer it in production code.
5. Real-World Use Cases
A) Freezing Configs
const CONFIG = Object.freeze({
API_URL: "https://api.example.com",
RETRIES: 3,
});
Guarantees global constants aren’t mutated.
B) Redux/State Management
function reducer(state, action) {
Object.freeze(state); // dev safeguard
// ...process action immutably
}
Helps catch accidental mutations during development.
C) Freezing Constants/Enums
const ROLES = Object.freeze({
ADMIN: "admin",
USER: "user",
GUEST: "guest",
});
Safe way to store constant sets.
D) Freezing Shared Objects in APIs
function init(options) {
const safeOptions = Object.freeze(options);
return safeOptions;
}
Prevents external consumers from mutating shared configs.
6. TypeScript & Object.freeze
TypeScript has its own static immutability helpers, but you can combine them with runtime freeze.
A) Readonly typing
const CONFIG = Object.freeze({
url: "https://api.example.com",
retries: 3,
});
// In TS: inferred as { readonly url: string; readonly retries: number }
B) Manual Readonly type
type User = { id: number; name: string };
const u: Readonly<User> = { id: 1, name: "Ali" };
👉 Readonly<T> gives compile-time safety; Object.freeze gives runtime safety. Use both when you need belts-and-suspenders.
7. Alternatives to Freezing
- Immutable libraries: Immer, [Immutable.js] — better ergonomics for complex state.
- Linting rules: ESLint
no-param-reassignhelps prevent mutation. - TypeScript: Static
Readonlytypes catch issues before runtime.
8. Gotchas & Limitations
- Shallow freeze: nested objects still mutable unless you deep freeze.
- Performance cost: Freezing adds overhead; avoid on hot paths or large objects.
- Not polyfillable: Works only in ES5+ environments.
- Array quirks: Frozen arrays can’t be modified, but still iterable.
const arr = Object.freeze([1,2,3]);
arr.push(4); // ❌ TypeError
5. Class instances: Methods still callable; only property mutability is blocked.
9. Handy Utility Functions
Deep freeze (safe export)
export const deepFreeze = (obj) => {
Object.getOwnPropertyNames(obj).forEach((prop) => {
if (
obj[prop] &&
typeof obj[prop] === "object" &&
!Object.isFrozen(obj[prop])
) {
deepFreeze(obj[prop]);
}
});
return Object.freeze(obj);
};
Immutable clone
Instead of freezing in place, clone + freeze:
const freezeClone = (obj) => deepFreeze(structuredClone(obj));
Conclusion
Object.freeze is a powerful but simple guard against unwanted mutations.
- ✅ Great for configs, enums, shared constants, and dev-time safety in reducers.
- ⚠️ Remember it’s shallow — use
deepFreezefor full immutability. - ⚡ Don’t overuse in hot paths; consider libraries like Immer for ergonomics.
- 🔒 Combine with TypeScript’s
Readonlyfor compile + runtime safety.
Pro Tip: Treat Object.freeze as a safety net, not your only tool. For app state, use immutability patterns or Immer; for constants/configs, freeze them once and forget about accidental bugs.
Call to Action (CTA)
Have you ever been bitten by a sneaky object mutation?
Share your story in the comments — and send this to a teammate who still mutates configs in place.


Leave a Reply