A TypeScript service declares its startup config with `readonly` properties, then uses a helper `type Mutable<T> = { -readonly [K in keyof T]: T[K] }` to build and patch that object during boot. What protection does the `readonly` annotation actually give, and how would you structure the code instead?
answer
- annotation, not enforcement
- the checker ignores it when comparing shapes
- you never needed the helper to defeat it
- mutate during construction only
- enforcement needs a runtime mechanism
basics
~20 sVery little on its own: readonly is a compile-time annotation that is erased, is ignored when TypeScript compares object types for assignability, and is stripped by any Mutable helper. Confine mutation to a construction function that returns the readonly view once.
solid answer
~50 s`readonly` on object properties is a checker-only annotation. It is erased at compile time, so nothing stops mutation at runtime, and TypeScript deliberately ignores property `readonly` modifiers when deciding whether two object types are assignable — a plain `{ host: string }` value flows into a `{ readonly host: string }` slot and back, so an aliased reference bypasses the annotation without any `Mutable` helper at all. Given that, `Mutable<T>` is not a hole you punched in a guarantee; it is an explicit local admission that this code mutates. The structure that actually holds is to keep the mutation inside one construction function — build a `Mutable<Config>` draft, patch it there, return it typed as the readonly `Config` — so every consumer downstream sees the readonly view, and reach for a runtime freeze only if you genuinely need enforcement.
code
typescript · 16 linestype Mutable<T> = { -readonly [K in keyof T]: T[K] };
interface Config {
readonly host: string;
readonly port: number;
}
function buildConfig(env: Record<string, string | undefined>): Config {
const draft: Mutable<Config> = { host: 'localhost', port: 8080 };
if (env.HOST) draft.host = env.HOST;
if (env.PORT) draft.port = Number(env.PORT);
return draft; // handed back as the readonly view
}
const cfg = buildConfig({ HOST: 'db.internal' });
// cfg.port = 1; // Error: Cannot assign to 'port' because it is a read-only propertygo deeper
Know that readonly stops assignment through that type in your editor and during compilation, and that it disappears from the JavaScript that actually runs.
Explain that the modifier is erased and shallow, and that a Mutable helper built with -readonly strips it for a specific binding rather than changing anything about the value itself.
Show the assignability subtlety — property readonly modifiers are ignored when object types are compared, so aliasing defeats the annotation without any helper — and scope mutation to a construction function that returns the readonly view.
Decide which problem the team actually has: a convention problem, where readonly plus review is enough, or an enforcement problem, where the type layer cannot help and you need runtime immutability or copy-on-read at the boundary.
## What readonly is, precisely `readonly` on a property means: the checker will reject an assignment to this property *through this type*. That is the whole guarantee. Three consequences follow, and a senior answer names all three. **It is erased.** Modifiers live in the type layer. The emitted JavaScript has no descriptors, no guards, no trace of `readonly` either way. An object typed with `readonly` properties is an ordinary mutable JavaScript object; code that never saw your types — a deserializer, a library callback, a plain `any` — mutates it freely. **It does not affect assignability between object types.** TypeScript does not consider property `readonly` modifiers when checking whether two object types are compatible: ```ts interface Frozen { readonly host: string } interface Loose { host: string } const loose: Loose = { host: 'a' }; const frozen: Frozen = loose; // OK const backAgain: Loose = frozen; // OK backAgain.host = 'b'; // OK — and it mutated the same object ``` So you never needed `Mutable<T>` to defeat the annotation. A structurally identical mutable type does it silently. (Note the contrast with `readonly` *array* types: `readonly string[]` is genuinely not assignable to `string[]`, because that is a different type relationship rather than a property modifier.) **It is shallow.** `readonly host: string` says nothing about a nested `readonly options: { retries: number }`; the inner object stays writable through the outer readonly property. ## So what is Mutable<T> doing? Given the above, `type Mutable<T> = { -readonly [K in keyof T]: T[K] }` is not breaking a lock — it is *labelling* a mutation. That is a genuinely useful thing. The helper makes the intent greppable, keeps the mutable view scoped to one binding, and lets the readonly type stay the shape every other module consumes. The problem is never the helper; it is a helper used at a distance, where `Mutable<Config>` leaks out of the construction site and into ordinary business code. ## The structure that holds Confine mutation to construction, widen back at the boundary: ```ts type Mutable<T> = { -readonly [K in keyof T]: T[K] }; interface Config { readonly host: string; readonly port: number; } function buildConfig(env: Record<string, string | undefined>): Config { const draft: Mutable<Config> = { host: 'localhost', port: 8080 }; if (env.HOST) draft.host = env.HOST; if (env.PORT) draft.port = Number(env.PORT); return draft; // returned as the readonly view } ``` Everything after `buildConfig` sees `Config`. The mutable window is a handful of lines, in one function, with a name that says what it is. Two alternatives are often better still: build the object in a single expression so there is nothing to mutate, or accumulate into a differently-shaped builder object and produce the `Config` at the end — that way `Mutable` never appears. ## When you need a real guarantee If the requirement is "nothing anywhere may change this at run time", the type layer cannot deliver it and no mapped type will. That needs a runtime mechanism — freezing the object, or handing out copies — and the JavaScript semantics of that (shallowness, silent versus throwing failures) are their own topic. The important judgment call is recognising which of the two problems you have: a *review* problem, where `readonly` and a scoped `Mutable` are exactly right, or an *enforcement* problem, where annotations are the wrong tool entirely. ## What to say in the interview Lead with erasure, then the assignability point — that second one is what distinguishes someone who has read the rules from someone repeating "types don't exist at runtime". Then make the design recommendation: mutation belongs in construction, the readonly view is what you publish, and `Mutable<T>` is an intentional, local, named escape hatch rather than a general-purpose unlocking tool. Mention shallowness as the third limit, and note that a config parsed from JSON has no guarantees at all until it is validated, regardless of how it was typed. ## Failure modes worth naming - `Mutable<Config>` used as a parameter type deep in the codebase, so half the modules can mutate shared config. - Assuming `readonly` survived into a value handed to a third-party library. - Assuming a `readonly` outer type protects a nested object. - Treating a `readonly` annotation as a thread-safety or immutability claim in a design document, when it is a lint-strength convention enforced only where the compiler can see both sides.
- If `readonly` is ignored when comparing object types, does the same hold for `readonly` arrays?No — that case is different. `readonly string[]` and `string[]` are distinct types, and the readonly one is not assignable to the mutable one, because the mutable type exposes methods like `push` that the readonly type does not have. The lenient rule applies to the `readonly` *property modifier* on object types, not to readonly array and tuple types.
- Would you rather ban `Mutable<T>` outright and build config in a single expression?Often yes. A single object literal, or a builder that produces the final shape at the end, removes the mutable window entirely and reads better than a draft-then-patch sequence. `Mutable<T>` earns its place when construction is genuinely incremental — merging layered sources, say — and even then it should live in one function rather than in a shared signature.
- How would you stop `Mutable<Config>` from spreading through the codebase?Keep it unexported and local to the construction module, so no other file can name the type, and make every public signature take or return the readonly `Config`. A lint rule banning the helper outside that file works too. The point is that the escape hatch should be impossible to reach from ordinary code rather than merely discouraged.
saying these in an interview costs you the question
- Says readonly prevents mutation at run time
- Assumes a mutable object type cannot be assigned to a readonly one
- Treats readonly as deep immutability of the whole object
- Uses Mutable<T> in shared public signatures
- Confuses the readonly modifier with a frozen object