Q38 of 40 · JavaScript

What does `Object.freeze()` do, and how does it compare to TypeScript's `readonly`?

JavaScriptSeniorjavascriptobject-freezeimmutabilitytypescriptreadonly

Short answer

Short answer: `Object.freeze()` is a runtime enforcement — frozen object properties cannot be added, deleted, or modified. It is shallow: nested objects are not frozen. TypeScript `readonly` is compile-time only — the JS output has no freeze call, so mutations are still possible at runtime.

Detail

These two mechanisms both restrict mutations but operate at different levels.

Object.freeze(): A runtime operation that makes an object non-extensible (no new properties), seals it (no deletions), and makes all own properties non-writable. Mutations silently fail in sloppy mode; they throw TypeError in strict mode.

Shallow freeze: Only the top-level is frozen. Properties that are objects still point to mutable references. To deep-freeze, you must recursively freeze all nested objects.

TypeScript readonly: A compile-time constraint. The transpiled JavaScript output contains no Object.freeze call — a readonly property can still be mutated at runtime through non-typed paths (type assertions, any, or runtime-generated values). TypeScript's as const infers deeply readonly literal types but still does not emit freeze.

When to use which:

  • Use Object.freeze() when you need a true runtime constant — configuration objects, lookup tables, test fixtures you want to protect from accidental mutation.
  • Use TypeScript readonly for compile-time contract enforcement — the common case.
  • Combine both for maximum safety on critical constants.

// EXAMPLE

// Object.freeze — runtime
const CONFIG = Object.freeze({
  baseUrl: "https://api.example.com",
  timeout: 5000,
  nested: { retries: 3 },   // NOT frozen — only shallow!
});

// "use strict";
// CONFIG.timeout = 9000;   // TypeError: Cannot assign to read only property
CONFIG.nested.retries = 10; // silently succeeds — nested not frozen!

// Deep freeze utility
function deepFreeze(obj) {
  Object.getOwnPropertyNames(obj).forEach(name => {
    const val = obj[name];
    if (typeof val === "object" && val !== null) deepFreeze(val);
  });
  return Object.freeze(obj);
}

// TypeScript readonly — compile-time only
interface Config { readonly timeout: number; }
const cfg: Config = { timeout: 5000 };
// cfg.timeout = 9000; // TypeScript error — but JS runtime allows it
(cfg as any).timeout = 9000; // succeeds at runtime!

// WHAT INTERVIEWERS LOOK FOR

The runtime vs compile-time distinction is the core insight. Shallow freeze limitation. The `as const` / `readonly` TypeScript patterns alongside their runtime limitations. Connecting to test fixture protection — frozen config objects prevent accidental test contamination.

// COMMON PITFALL

Assuming `Object.freeze()` deep-freezes nested objects — it does not. A frozen object with a nested object property still allows mutation of the nested object. Deep freeze requires a recursive traversal.