skip to content

A TypeScript object whose property is declared `readonly` still gets overwritten at runtime, with no compile error anywhere in the codebase. What holes in the modifier allow that, and what would actually stop it?

level: seniorimportance: should knowfreq 40%

answer

  1. a view, not a value
  2. two names for one object
  3. the checker never compares the modifier
  4. arrays are the exception, not the rule
  5. only runtime code can really stop it

basics

~20 s

Assignability ignores readonly on object properties, so the value can be assigned to an alias typed without it and written through that alias. The modifier is also shallow and erased, so nested data, assertions, and untyped callers bypass it entirely.

solid answer

~50 s

The main hole is aliasing: when the checker compares two object types it does not consider whether their properties are `readonly`, so a `{ readonly id: number }` is assignable to `{ id: number }` with no error, and writing through that second reference mutates the same object. Add the other two escapes — the modifier is shallow, so anything nested is fair game, and it is erased, so a type assertion, an `any`, or a plain JavaScript caller writes freely — and you have covered every way it leaks. Note the asymmetry with arrays: `readonly` there is enforced, since `readonly string[]` lacks the mutating methods and is genuinely not assignable to `string[]`. Actually preventing runtime mutation needs runtime code — freezing the object or never handing out the mutable reference — because no type-layer modifier can survive erasure.

code

typescript · 12 lines
typescript
interface Frozen { readonly id: number }
interface Mutable { id: number }

const f: Frozen = { id: 1 };
const m: Mutable = f;   // allowed: readonly is not part of assignability
m.id = 2;
console.log(f.id);      // 2

// arrays behave differently — the modifier is enforced there
declare const ro: readonly string[];
// const rw: string[] = ro;          // error: mutating methods are missing
const safe: readonly string[] = ['a']; // the safe direction is fine

go deeper

for a junior

Know that readonly blocks a direct assignment through that variable and nothing else — the same object reached by another name, or from untyped code, can still be changed.

for a middle

Explain the three escapes concretely: assignability ignores the modifier so an alias may be typed mutable, the modifier is shallow, and it is erased so assertions and plain JavaScript bypass it.

for a senior

Diagnose the aliasing case from a bug report, distinguish it from the array behaviour where readonly is genuinely enforced, and say which runtime mechanism you would reach for when the data must actually not change.

for a principal

Own the boundary policy — decide where your system needs checked intent, where it needs defensive copies or frozen state, and what that costs in allocations and in every consumer's assignability.

## The reproduction ```ts interface Frozen { readonly id: number } interface Mutable { id: number } const f: Frozen = { id: 1 }; const m: Mutable = f; // no error m.id = 2; f.id; // 2 — the readonly property changed ``` Nothing here is a bug in the compiler. When TypeScript checks whether one object type is assignable to another, it compares property names and property types; the `readonly` modifier on a property is **not** part of that comparison. Two aliases to one object, one of which promises not to write and one of which does not, are considered compatible, and the write goes through. This is a documented, deliberate unsoundness. Tracking readonly-ness through assignability would make an enormous amount of ordinary code fail — every function taking a mutable-typed parameter would reject readonly arguments — and TypeScript chose usability. Knowing that it *is* a choice, and not something you can configure away, is the senior-level part of the answer. ## The other two escapes **Shallowness.** The modifier covers the property slot only. Anything reachable through it is untouched: ```ts interface Session { readonly user: { name: string } } declare const s: Session; s.user.name = 'mallory'; // fine — no alias needed, no assertion ``` Most real-world "readonly didn't help" incidents are this one, not the aliasing hole, because it needs no second type at all. **Erasure.** `readonly` produces no JavaScript. So an assertion, a value that passed through `any`, JSON parsed at a boundary, or a call from a plain `.js` file all write without resistance: ```ts (s as { user: { name: string } }).user = { name: 'x' }; // compiles ``` A library consumer who does not use TypeScript is not constrained by your declarations at all. ## The array asymmetry It is worth knowing that `readonly` behaves differently on array types, because the mechanism is different. `readonly string[]` is not "a `string[]` with a modifier the checker ignores" — it is a distinct type that simply does not declare `push`, `pop`, `splice`, or index assignment. Structural checking therefore rejects the dangerous direction: ```ts declare const ro: readonly string[]; const rw: string[] = ro; // error — the mutating members are missing const back: readonly string[] = rw; // ok — the safe direction ``` So the aliasing hole is specific to `readonly` **properties** on object types. Candidates who have only read "readonly is unsound" often over-generalise it to arrays and get this backwards. ## What actually stops the mutation Since no type-layer modifier survives compilation, real prevention is runtime work or structural discipline: - **Freeze the object** before handing it out. That makes the write fail at runtime — silently in sloppy mode, as a `TypeError` in strict mode — and it is still shallow, so a deep guarantee means walking the graph. - **Never expose the mutable reference.** Return a copy, or expose accessor functions instead of the object. If no alias exists, no alias can be typed mutably. - **Deepen the types anyway.** `readonly user: { readonly name: string }` and `readonly tags: readonly string[]` do not close the aliasing hole, but they close the far more common shallowness hole, and they are free. ## How to talk about it The framing that lands in an interview: `readonly` marks a *view*, not a *value*. It is checked only on writes made through that view, only one level deep, and only in code the checker sees. That is genuinely useful — it catches the accidental assignment inside your own module and it documents an API's intent at zero runtime cost — but it is not immutability, and the moment your requirement is "this data must not change", you have left the type system and entered runtime territory. A candidate who names the aliasing hole *and* correctly excludes arrays from it, then separates "catches mistakes" from "enforces a guarantee", has demonstrated exactly the kind of judgment the question is testing.

  • Why does TypeScript ignore readonly when checking assignability between object types?
    Usability. Tracking it would make every function that takes a mutable-typed parameter reject readonly arguments, breaking a huge amount of ordinary code and most existing declarations. It is a deliberate, documented unsoundness rather than an oversight, and no compiler flag turns it into an error.
  • Does the same hole exist for `readonly string[]` versus `string[]`?
    No — and this is the asymmetry worth knowing. `readonly string[]` is a different type that simply lacks `push`, `splice`, and index assignment, so structural checking rejects assigning it to `string[]`. The safe direction, mutable to readonly, is allowed. The hole is specific to readonly properties on object types.
  • If readonly cannot be relied on, why mark anything readonly at all?
    Because it catches the accidental write inside your own code, documents intent on a public surface, and costs nothing at runtime. It is a defect filter, not a guarantee. Reserve the runtime mechanisms — freezing, copying, or never exposing the reference — for data whose immutability is actually load-bearing.

saying these in an interview costs you the question

  • Says readonly is enforced across assignability like a type mismatch
  • Thinks a readonly array can be assigned to a mutable array type
  • Claims a compiler flag closes the aliasing hole
  • Believes readonly protects nested objects
  • Treats readonly as a runtime immutability guarantee

context