readonly & as const

Reviewed & published by Brayan K

readonly and as const are compile-time guarantees that block reassignment in your editor, while Object.freeze is the runtime tool that actually prevents mutation when the code runs.

Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Imagine a museum display. A "Do Not Touch" sign (readonly) tells visitors not to handle the exhibit — but a determined person could still reach in; it's a rule, not a barrier. A glass case (Object.freeze) physically prevents touching at runtime. And the glass case only covers what's inside it — items on a shelf behind the case (nested objects) are still reachable unless you case those too (deep freeze).

1. readonly Properties

Mark a property readonly and TypeScript lets you set it when the object is created but rejects any later reassignment. It's perfect for identifiers and configuration that shouldn't change after construction. The crucial caveat: this is a compile-time guarantee only. It's erased before the code runs, so the JavaScript engine itself imposes no restriction.

interface Point { readonly x: number; readonly y: number }

const p: Point = { x: 1, y: 2 };
// p.x = 9; // ❌ Cannot assign to 'x' because it is a read-only property

// 'readonly' is purely a TYPE-level guarantee — it tells the compiler
// "don't let anyone reassign this." It is erased at runtime, so the JS engine
// will happily mutate the object unless you ALSO freeze it.

// You can mix readonly and mutable members in the same shape:
interface Counter { readonly id: string; count: number }
const c: Counter = { id: "abc", count: 0 };
c.count++;       // ✅ allowed
// c.id = "xyz"; // ❌ readonly
// 'readonly' marks a property you can SET once but not REASSIGN afterwards.
// In TS:  interface Point { readonly x: number; readonly y: number }
//   p.x = 9; // ❌ Cannot assign to 'x' because it is a read-only property
// IMPORTANT: 'readonly' is COMPILE-TIME only — at runtime nothing stops you.

const point = { x: 1, y: 2 }; // in TS these would be readonly
console.log(point.x, point.y); // 1 2

// In TS, point.x = 5 would be an ERROR. At runtime it's still allowed —
// so 'readonly' protects you while CODING, not while RUNNING:
point.x = 5;                  // TS would reject this; JS permits it
console.log(point.x);         // 5

// To ENFORCE immutability at runtime you need Object.freeze (next section).

2. as const, ReadonlyArray & Readonly<T>

Three related tools build on readonly. as const applied to a literal makes it deeply readonly and pins each value to its narrowest literal type. A readonly T[] (or ReadonlyArray<T>) removes mutating methods like push from the type. And the Readonly<T> utility flips every property of an existing object type to readonly in one step.

// 'as const' = deeply readonly + narrowest literal types:
const cfg = { mode: "dark", retries: 3 } as const;
// type: { readonly mode: "dark"; readonly retries: 3 }
// cfg.mode = "light"; // ❌ readonly

// ReadonlyArray<T> (or 'readonly T[]') removes mutating methods:
const sizes: readonly string[] = ["sm", "md", "lg"];
// sizes.push("xl"); // ❌ Property 'push' does not exist on type 'readonly string[]'

// Readonly<T> flips every property of an object type to readonly:
type Config = { mode: string; retries: number };
type FrozenConfig = Readonly<Config>;
// { readonly mode: string; readonly retries: number }
// 'as const' makes a value DEEPLY readonly AND pins literals — a great way to
// freeze config in the types. In TS:  const cfg = { mode: "dark" } as const;
//   cfg.mode = "light"; // ❌ Cannot assign to 'mode' (it's readonly)
// At runtime 'as const' adds nothing, so we demo the BEHAVIOUR it locks in.

const config = { mode: "dark", retries: 3 }; // 'as const' -> deeply readonly
console.log(config.mode + " / " + config.retries); // dark / 3

// ReadonlyArray<T> / readonly T[] forbid push/pop/splice IN THE TYPES:
const sizes = ["sm", "md", "lg"]; // 'as const' -> readonly ["sm","md","lg"]
console.log(sizes.length); // 3
// In TS:  sizes.push("xl"); // ❌ Property 'push' does not exist on a readonly array

// Readonly<T> is a utility that flips every property of T to readonly:
//   type Frozen = Readonly<{ a: number; b: number }>;
const frozenLike = { a: 1, b: 2 };
console.log(frozenLike.a + frozenLike.b); // 3

3. Compile-time readonly vs Runtime Object.freeze

These two solve different problems. readonly and as const catch mistakes in your editor and vanish at runtime. Object.freeze is real, executing code that actually blocks mutation when the program runs (throwing in strict mode, silently ignoring otherwise). But freeze is shallow: it locks only the top level, leaving nested objects mutable. For full safety you often want both — types for the editor, freeze for the runtime.

// Two layers of immutability — use both for real safety:

// 1) COMPILE TIME — the type system stops you in the editor:
const cfg = { theme: "dark" } as const; // readonly in the types

// 2) RUN TIME — Object.freeze actually blocks the mutation when it runs:
const frozen = Object.freeze({ theme: "dark" });
// frozen.theme = "x"; // throws in strict mode; silently ignored otherwise

// Object.freeze is SHALLOW. For deep runtime immutability you recurse into
// nested objects yourself (see the mini-challenge). At the TYPE level, a
// DeepReadonly<T> helper does the equivalent — covered in Recursive Types.
// COMPILE-TIME readonly vs RUNTIME Object.freeze.
// 'readonly' / 'as const' stop you in the EDITOR. Object.freeze stops you at
// RUNTIME — actually preventing the mutation when the code executes.

const settings = Object.freeze({ theme: "dark", volume: 7 });

// In non-strict mode the assignment SILENTLY fails (no change, no throw):
settings.theme = "light";
console.log(settings.theme);          // dark   (freeze blocked it!)

// Object.isFrozen confirms the runtime lock:
console.log(Object.isFrozen(settings)); // true

// Object.freeze is SHALLOW — nested objects are NOT frozen:
const nested = Object.freeze({ user: { name: "Ada" } });
nested.user.name = "Bo";              // inner object is still mutable
console.log(nested.user.name);        // Bo

// Summary: readonly/as const = types (compile time);
//          Object.freeze     = real protection (run time, but shallow).

🎯 Your Turn

Make an object truly immutable at runtime. Fill in the blank marked ___ with the right method, then run it.

// 🎯 YOUR TURN — replace the ___ then press "Try it Yourself".
// 'lock' should make the object truly immutable AT RUNTIME.
// Hint: the method that freezes an object is Object.freeze

function lock(obj) {
  return Object.___(obj); // 👉 the method name: freeze
}

const cfg = lock({ debug: false });
cfg.debug = true; // blocked at runtime by freeze

console.log(cfg.debug);             // false
console.log(Object.isFrozen(cfg));  // true

// ✅ Expected output:
//    false
//    true

Common Errors (and the fix)

Pro Tips

📚 Keep learning

Mini-Challenge: Deep Freeze

No blanks this time — just a brief and a starting outline. Build the recursive freeze yourself, run it, and check your output against the example in the comments.

// 🎯 MINI-CHALLENGE: deep freeze (Object.freeze is only shallow).
// In TS, the type-level equivalent is DeepReadonly<T>; here we build the
// RUNTIME version so even nested objects can't be mutated.
//
// Write deepFreeze(obj) that:
//   1. For every property that is itself an object, recurses (deepFreeze it).
//   2. Then freezes obj and returns it.
//
// Test that BOTH the top level and the nested 'address' resist mutation.
//
// ✅ Expected output:
//    Ada
//    London
//    true

// your code here
// const data = deepFreeze({ name: "Ada", address: { city: "London" } });

🎉 Lesson Complete

Practice quiz

When does 'readonly' actually prevent a property from changing?

  • At runtime, always
  • Only inside strict mode at runtime
  • At compile time only - it is erased before the code runs
  • Never, it is just a comment

Answer: At compile time only - it is erased before the code runs. readonly is a type-level guarantee; the JS engine imposes no restriction at runtime.

What does 'as const' do to a literal value?

  • Makes it deeply readonly and pins narrow literal types
  • Freezes it at runtime
  • Converts it to a string
  • Adds a runtime guard

Answer: Makes it deeply readonly and pins narrow literal types. as const infers the narrowest literal types and makes the whole value deeply readonly in the types.

Can an interface mix readonly and mutable members?

  • No, all members must match
  • Only if you use as const
  • Only in classes
  • Yes - e.g. readonly id with a mutable count

Answer: Yes - e.g. readonly id with a mutable count. interface Counter { readonly id: string; count: number } is valid; count can change, id cannot.

Which tool actually blocks mutation when the program runs?

  • readonly
  • Object.freeze
  • as const
  • the Readonly<T> utility

Answer: Object.freeze. Object.freeze is real runtime code; readonly and as const are type-only.

Why is Object.freeze described as shallow?

  • It freezes only top-level properties, leaving nested objects mutable
  • It only freezes arrays
  • It freezes nothing
  • It only works once per object

Answer: It freezes only top-level properties, leaving nested objects mutable. Nested objects stay mutable unless you recursively deep-freeze them.

What happens when you call push on a 'readonly string[]'?

  • It works fine
  • It throws at runtime
  • TypeScript errors: 'push' does not exist on a readonly array
  • It returns undefined

Answer: TypeScript errors: 'push' does not exist on a readonly array. Readonly arrays remove mutating methods like push from the type.

What does the Readonly<T> utility type do?

  • Freezes the object at runtime
  • Flips every property of T to readonly
  • Removes optional properties
  • Makes T deeply immutable including nested

Answer: Flips every property of T to readonly. Readonly<T> maps every top-level property of T to readonly (shallow).

In non-strict mode, assigning to a frozen object's property does what?

  • Throws a TypeError
  • Updates the value
  • Deletes the property
  • Silently fails with no change

Answer: Silently fails with no change. In non-strict mode the assignment silently fails; in strict mode it throws.

How do you confirm an object is frozen at runtime?

  • typeof obj === 'frozen'
  • Object.isFrozen(obj)
  • obj.frozen
  • Object.readonly(obj)

Answer: Object.isFrozen(obj). Object.isFrozen returns true for a frozen object.

For deep runtime immutability of nested objects you should:

  • Use readonly everywhere
  • Call Object.freeze once
  • Recursively freeze nested objects before freezing the parent
  • Use as const

Answer: Recursively freeze nested objects before freezing the parent. A recursive deepFreeze walks into each nested object before freezing the parent.

Continue this course

Frequently asked questions

Does readonly stop mutation at runtime?

No. 'readonly' (and 'as const') are compile-time only — they tell the TypeScript checker to reject reassignment in your editor, then they're erased before the code runs. At runtime the JavaScript engine will happily mutate a 'readonly' property. If you need the mutation to actually be blocked when the program executes, you must use Object.freeze, which is a real runtime mechanism.

What's the difference between readonly and as const?

'readonly' marks individual properties (or an array) as non-reassignable, and it's typically applied one member at a time in an interface or type. 'as const' is applied to a whole literal value and does two things at once: it infers the narrowest possible literal types (so 'dark' stays 'dark', not widening to string) and makes the entire value deeply readonly. Use 'readonly' on shapes you author; use 'as const' to lock down a literal value like a config object or tuple.

Why is Object.freeze called shallow?

Object.freeze only freezes the object's own top-level properties. If a property holds another object, that inner object is left fully mutable. So Object.freeze({ user: { name: 'Ada' } }) prevents replacing 'user', but you can still change user.name. To make every level immutable you write a recursive deepFreeze that freezes nested objects before freezing the parent — which is exactly this lesson's mini-challenge.

When should I prefer immutability?

Immutability shines for configuration, constants, and any shared state where accidental mutation causes hard-to-trace bugs — especially in concurrent or reactive code where the same object is read in many places. Lock literal config with 'as const', type shared shapes with 'readonly', and reach for Object.freeze when you want a runtime guarantee. Local, short-lived data that you build up step by step is usually fine to keep mutable.