A shared configuration object in a TypeScript service is declared with `as const`. A teammate says this makes it immutable. Is that accurate, and how would you actually guarantee the object is not mutated at runtime?
answer
- types are erased at emit
- readonly is checked, not enforced
- assignability ignores readonly modifiers
- one mutable alias defeats it
- freeze is shallow and costs
basics
~20 sNo. A const assertion is a compile-time constraint that is erased at emit — it produces no runtime protection. Direct writes are rejected by the checker, but any alias typed as mutable, any cast, or any untyped consumer can still change the object. Runtime immutability needs a runtime mechanism.
solid answer
~50 s`as const` marks the properties `readonly` in the type layer only; the emitted JavaScript is the plain object literal, with no freeze and no checks. So it stops the writes the compiler can see, which is real value, but it is not a guarantee. The escape hatches are easy to hit accidentally: assigning the object to a variable annotated with a mutable shape is allowed, because TypeScript ignores `readonly` modifiers when checking property assignability; an `as any` or a `JSON.parse` boundary loses the modifiers entirely; and untyped or JavaScript consumers never saw them at all. If mutation must actually be impossible — say, the config is shared across modules and something is suspected of writing to it — you need a runtime mechanism such as freezing the object, and you must decide whether shallow freezing is enough for a nested table. Better still, do not share a mutable reference: hand out copies or accessor functions.
code
typescript · 13 linesconst CONFIG = { retries: 3 } as const;
// The checker blocks the obvious write:
// CONFIG.retries = 5; // error: readonly property
// But readonly is ignored when checking assignability,
// so an ordinary mutable alias gets through:
const alias: { retries: number } = CONFIG;
alias.retries = 5;
console.log(CONFIG.retries); // 5 — the assertion never existed at runtime
// An actual runtime guarantee needs a runtime mechanism:
const FROZEN = Object.freeze({ retries: 3 } as const);go deeper
Remember the headline: as const is a compile-time constraint only. It stops you writing to the object in your own checked code, and it disappears entirely from the JavaScript that ships.
Explain the erasure concretely — what the emitted code looks like — and be able to demonstrate the mutable-alias escape without resorting to any or a cast.
Bring the judgment: rank not sharing a mutable reference, a type-check gate, freezing at the boundary and dev-only freezing, and say which one you would apply to a shared config and why.
Own the policy across a codebase: which invariants deserve runtime enforcement at all, what freezing costs on hot paths, and how you keep the type-level story honest at boundaries where data arrives unchecked.
## What is actually emitted TypeScript's type layer is erased. This source: ```ts const CONFIG = { retries: 3, endpoints: { api: '/v1' } } as const; ``` compiles to exactly this JavaScript: ```js const CONFIG = { retries: 3, endpoints: { api: '/v1' } }; ``` No assertion, no `readonly`, no freeze, no wrapper. `readonly` is a rule the *checker* enforces on code it is asked to check — it is not a property of the object at runtime. Same for the literal types: nothing in the running program knows `retries` is meant to be `3`. ## What it does buy you This is not nothing. Inside the checked codebase, `CONFIG.retries = 5` is a compile error, and so is `CONFIG.endpoints.api = '/v2'` because the assertion applies recursively. Every direct write the compiler can see is caught before the code ships. For a team that runs a type-check gate in CI, that covers the overwhelming majority of accidental mutations. ## The holes The interesting part of this question is knowing where the guarantee stops. **Aliasing through a mutable type.** TypeScript deliberately ignores `readonly` property modifiers when deciding assignability between object types. So this compiles, with no assertion and no `any` anywhere: ```ts const CONFIG = { retries: 3 } as const; const alias: { retries: number } = CONFIG; // allowed alias.retries = 5; // and now CONFIG.retries is 5 ``` That is a known unsoundness in the model, and it is the one to be able to name in an interview. **Assertions and `any`.** `(CONFIG as any).retries = 5` compiles, as does routing the object through any helper typed loosely enough. An assertion suppresses the check; it does not add one. **Values that never had the type.** Anything crossing a runtime boundary — `JSON.parse`, a message from another process, a value read from a plugin — arrives with whatever type you claim for it, and claiming is not checking. **Unchecked consumers.** Plain JavaScript callers, or a package consuming your build output, never see the modifiers at all. ## Getting an actual guarantee If mutation must be impossible rather than merely discouraged, the enforcement has to exist at runtime. `Object.freeze` is the standard tool; note that it is shallow, so a nested configuration table needs the freeze applied recursively, and that recursion has a real cost on large structures. Freezing also improves the *type*: the standard library types `Object.freeze` so that it returns a `Readonly` view of its argument, so you get the compile-time modifiers as well — though not the literal types, since the literal has already been widened by the time it is passed in. Applying the const assertion to the literal and freezing the result gives you both. ```ts const CONFIG = Object.freeze({ retries: 3 } as const); ``` ## The judgment an interviewer is listening for Reaching straight for a deep freeze is usually the wrong instinct. Rank the options: 1. **Do not share a mutable reference.** The strongest fix is structural: expose a function that returns the value the caller needs, or a copy, rather than exporting the object itself. Nothing to mutate, nothing to freeze. 2. **`as const` plus a type-check gate in CI.** Cheap, catches every write in your own code, costs nothing at runtime. For most internal configuration this is the right stopping point. 3. **Freeze at the boundary.** Add a runtime freeze when the object escapes into code you do not check — a public package API, a plugin surface, anything crossing into JavaScript. Freeze once at module initialisation, not on every read. 4. **Freeze in development only.** A common compromise for hot paths: enforce during development and tests where a violation will be caught, skip the cost in production. And if the real complaint is "something mutates our config and we cannot find it", the diagnostic move is a temporary freeze in a non-production build, so the offending write fails loudly and the stack trace tells you who did it. ## Summary sentence to give "`as const` is an author-time constraint, not a runtime one: it is erased, it is defeated by an ordinary mutable alias because readonly is ignored in assignability, and if the invariant genuinely matters I would not export a shared mutable reference in the first place — or I would freeze it at the boundary."
- Show the shortest way to defeat a const assertion without using `any` or a type assertion.Assign the object to a binding annotated with the same shape but mutable properties, then write through it. TypeScript ignores `readonly` modifiers when checking property assignability, so the assignment is accepted and the write compiles. It is a deliberate hole in the model, not a bug in your code.
- If you do freeze the config, does that change its TypeScript type?Yes — the standard library types `Object.freeze` to return a `Readonly` view of its argument, so you get readonly modifiers from the call itself. What you do not get back are literal types, since the argument's literals were already widened. Applying the const assertion to the literal before freezing keeps both.
- When would you deliberately not freeze a configuration object?When the type-check gate already covers every consumer, when the object is large or hot enough that recursive freezing costs real time, or when the better fix is structural — not exporting a shared reference at all. Freezing is for boundaries you do not control, not a default for internal constants.
saying these in an interview costs you the question
- Says as const makes the object immutable at runtime
- Thinks as const compiles to Object.freeze
- Assumes readonly blocks assignment to a mutable-typed alias
- Believes Object.freeze is deep
- Trusts readonly on data that came from JSON.parse