How to Freeze Objects for Safety in JavaScript

Posted by

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.

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.

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 to false.
  • 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-reassign helps prevent mutation.
  • TypeScript: Static Readonly types catch issues before runtime.

8. Gotchas & Limitations

  1. Shallow freeze: nested objects still mutable unless you deep freeze.
  2. Performance cost: Freezing adds overhead; avoid on hot paths or large objects.
  3. Not polyfillable: Works only in ES5+ environments.
  4. 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 deepFreeze for full immutability.
  • ⚡ Don’t overuse in hot paths; consider libraries like Immer for ergonomics.
  • 🔒 Combine with TypeScript’s Readonly for 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

Your email address will not be published. Required fields are marked *