skip to content

In TypeScript, `readonly` on a class property is often called an unenforceable promise. What are the concrete ways a value can still be written despite it?

level: middleimportance: should knowfreq 44%

answer

  1. nothing survives to run time
  2. assignability ignores the modifier
  3. an alias with a mutable type
  4. as strips it, no check
  5. protects the slot, not the object

basics

~20 s

Four routes: the modifier is erased, so untyped code writes freely; assignability ignores readonly, so a mutable-typed alias can write through; a type assertion strips it; and it is shallow, so the object it points at stays mutable.

solid answer

~50 s

There are four, and they are worth naming separately. First, erasure: `readonly` lives only in the type layer, so the emitted class has an ordinary writable property and any JavaScript consumer, or anything reached through `any`, writes it without complaint. Second, assignability: TypeScript deliberately ignores `readonly` differences when comparing object types, so you can assign the instance to a variable typed with the same shape minus the modifier and write through that alias — no error, no cast. Third, an assertion — `(obj as { url: string }).url = "x"` — removes it outright, since `as` performs no check. Fourth, shallowness: `readonly items: string[]` protects the property, not the array, so `push` and index writes are all legal. If you need a guarantee that survives untyped callers, that is a runtime concern and needs a runtime mechanism, not a modifier.

go deeper

for a junior

Know the two easy ones: readonly disappears when the code is compiled, and it only guards the property itself, not the object or array stored in it.

for a middle

Be able to demonstrate the silent alias — assigning the instance to a variable typed with the same shape but no modifier, then writing through it — and explain that assignability ignores readonly by design.

for a senior

Judge where the hole actually matters: public library surfaces, deserialization boundaries, objects handed to untyped consumers, and say which runtime mechanism you would spend there and what it costs.

for a principal

Be ready to state the team's position on compile-time versus enforced immutability — which boundaries in the system are allowed to rely on a modifier alone, and where a published surface must pay for a real guarantee.

## What readonly is checked against `readonly` asks the type checker to reject an assignment expression whose target is that property, outside the declaration and the declaring class's constructor. That is the whole feature. Everything below follows from noticing that the checker only sees what it is asked to check, and only at compile time. ## Route 1 — erasure TypeScript emits JavaScript with the type layer removed. There is no `readonly` in the output, no guard, no descriptor change. So the property is writable by: - plain JavaScript that imports your compiled module; - anything that reached the object through `any`; - a JSON round-trip, a deserializer, an object spread reassembled elsewhere. This is not a defect in the modifier; it is the boundary the whole language sits on. It does mean `readonly` protects your codebase, not your API's consumers. ## Route 2 — assignability ignores readonly This one surprises people because no cast is involved. When TypeScript compares two object types for assignability, a difference in `readonly` modifiers is not a mismatch: ```ts class Config { readonly url = "https://a.example"; } const c = new Config(); const alias: { url: string } = c; // no error alias.url = "https://b.example"; // no error console.log(c.url); // "https://b.example" ``` The assignment on the second line is accepted because the two types have the same members with the same value types. Once you hold the alias, the modifier is simply not present on the type you are writing through. This is a known, deliberate unsoundness: modelling `readonly` as variance-affecting would break enormous amounts of ordinary code, and the language chose the compatible option. Knowing this is what separates "readonly makes it immutable" from an accurate account. ## Route 3 — assertions `as` is an assertion, not a conversion; it performs no check and emits nothing. So `(c as { url: string }).url = "x"` writes the property with the compiler's blessing. This is occasionally deliberate — a factory or deserializer that fills in fields the constructor could not — and in that role it is defensible precisely because it is visible and greppable. Used casually, it is the same lie as any other assertion. ## Route 4 — shallowness The modifier applies to the property, not to the value. Anything reachable through it is untouched: ```ts class Basket { readonly items: string[] = []; readonly meta: { owner: string } = { owner: "me" }; } const b = new Basket(); b.items.push("apple"); // fine b.meta.owner = "you"; // fine b.items = []; // Error — the property itself ``` Deepening the guarantee means saying it in the element types too — a readonly array type for the collection, a mapped-over type for the nested object. There is no recursive form built into the modifier. ## What to do when you need a real guarantee If the requirement is "a consumer must be unable to change this at run time", the type layer is the wrong tool by construction. That is a JavaScript-level concern: hide the state behind a truly private field and expose only a getter, hand out copies instead of references, or freeze the object at construction. Each has a real cost — an extra allocation, a lost identity, a write that either fails silently or throws depending on the surrounding code — and you spend it only when an untrusted or untyped consumer actually exists. ## What to say in an interview The strong answer is not "readonly is useless". It is: `readonly` is a cheap, zero-runtime-cost way to catch accidental mutation inside a typed codebase, and it catches a great deal of it. It is not a security boundary, not a runtime invariant, and not deep. Name the four routes, say which ones require the developer to do something unusual (the assertion) and which happen silently (aliasing, and erasure at a JavaScript boundary), and finish with the rule: use it everywhere by default, and reach for a runtime mechanism only at the edges where untyped callers exist.

  • Why did TypeScript choose to ignore `readonly` when checking assignability between object types?
    Because making it variance-affecting would reject huge amounts of reasonable code — every function taking a mutable parameter would refuse a readonly-typed argument, and the reverse. The language accepted a known hole in exchange for compatibility. It is one of the documented unsound spots you are expected to be able to name.
  • How would you make the guarantee deep for a nested object property?
    Say it in the inner type rather than expecting the outer modifier to propagate: mark the nested members readonly too, or map over the shape to add the modifier recursively. The outer `readonly` only ever governs the one property slot it is written on.
  • Given all this, is `readonly` still worth using?
    Yes — it is free at run time and catches the overwhelmingly common case, which is your own code accidentally reassigning a field that was meant to be set once. Treat it as a design statement and a compile-time seatbelt, and reserve runtime mechanisms for the boundaries where untyped or untrusted callers actually reach the object.

saying these in an interview costs you the question

  • Claims a readonly field can never be written
  • Thinks a cast is required to defeat it
  • Says readonly deep-freezes nested objects
  • Assumes compiled JavaScript still enforces it
  • Confuses readonly with a private field

context