LEARN · DEBUGGING GUIDE

Debugging TypeScript Readonly Property Violations

A readonly violation means some code path is writing to a property marked readonly. The fix isn't just removing readonly—it's finding the real mutation source.

IntermediateTypeScript7 min read

What this usually means

Some code is attempting to write to a property that was declared with the `readonly` modifier. TypeScript's structural type system checks readonly at compile time, but there are several ways to bypass it accidentally: using type assertions (`as`), assigning to a non-readonly alias, mutating via methods that modify in place (like `push` on a readonly array), or not propagating readonly through nested types. The error often points to the line of assignment, but the root cause may be a design issue where a shared mutable reference is being modified unexpectedly.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Look at the exact line in the TS error: it shows where the assignment happens. Check if the variable is declared readonly or if it's a type assertion.
  • 2Search for `as` type assertions on that variable or its parent object. Type assertions can override readonly.
  • 3Inspect if the property is part of a class with `readonly` or an interface/type alias. If it's an array, check for mutation methods like `push`, `pop`, `splice`.
  • 4Check if the object is passed to a function that expects a mutable type. TypeScript's compatibility may allow a readonly array to be assigned to a mutable parameter.
  • 5Use `tsc --noEmit --pretty` to get the full error message and trace. Sometimes the error is in a different file than the mutation.
  • 6If using `Object.freeze`, ensure the freeze is applied at runtime; TypeScript readonly is compile-time only.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchThe `tsconfig.json` file: check if `strict` is enabled (includes `strictFunctionTypes` and `strictNullChecks`).
  • searchThe `.d.ts` files for third-party libraries: they may not have proper readonly annotations.
  • searchThe function signature where the object is passed: if the parameter is not readonly, mutation is allowed.
  • searchThe `package.json` and lock files: outdated type definitions may lack readonly.
  • searchThe reducer or state management code: Redux-style reducers often require immutable updates.
  • searchThe array methods being called: `Array.prototype.sort()` mutates in place, while `toSorted()` returns a new array.
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningUsing `as` type assertion to assign to a readonly property, e.g., `(obj as any).prop = value`.
  • warningAliasing a readonly object to a non-readonly variable: `const mutable = readonlyObj; mutable.prop = 1;`
  • warningCalling mutation methods on readonly arrays (e.g., `readonlyArr.push(1)`). TypeScript allows this if the array is assigned to a mutable variable first.
  • warningNot marking nested properties as readonly: `readonly { a: { b: number } }` allows `obj.a.b = 5`.
  • warningAccidentally using `Object.assign` or spread operator that shallow copies, allowing deeper mutation.
  • warningClass property declared with `readonly` but then assigned in a method instead of the constructor.
  • warningUsing `this.prop = value` inside a class method where `prop` is readonly.
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildRemove the type assertion and redesign the code to work with readonly constraints.
  • buildChange the function parameter to accept `Readonly<T>` or use `ReadonlyArray<T>` for arrays.
  • buildUse the spread operator or `Object.assign` to create new objects instead of mutating: `{ ...obj, prop: newValue }`.
  • buildFor arrays, use non-mutating methods like `map`, `filter`, `concat`, or the new `toSorted` and `toReversed`.
  • buildDeeply freeze objects at runtime with `Object.freeze` recursively, or use libraries like `immer`.
  • buildDeclare nested properties as `readonly` recursively using `DeepReadonly<T>` utility type.
  • buildUse `const` assertions (`as const`) for literal types to get deeply readonly types.
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedRun `tsc --noEmit` and confirm zero errors on the file.
  • verifiedWrite a unit test that attempts to mutate the readonly property and expects a compile-time or runtime error.
  • verifiedUse `Object.isFrozen()` at runtime to verify the object is frozen if using `Object.freeze`.
  • verifiedCheck that the state in a reducer does not change reference unexpectedly using `===`.
  • verifiedRun the linter with `@typescript-eslint/prefer-readonly` to ensure readonly is used consistently.
  • verifiedVerify with a type test using `expect-type` or `tsd` to ensure the type is readonly.
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningSimply removing `readonly` modifier to make the error go away without addressing the mutation.
  • warningUsing `as any` or `as unknown` to bypass the type checker—this defeats the purpose.
  • warningAssuming shallow readonly is enough for nested objects; use `DeepReadonly` or `as const`.
  • warningMutating arrays with `Array.prototype.sort()` in place; use `toSorted()` if available.
  • warningForgetting that `const` declarations do not make properties immutable, only the binding.
  • warningMixing mutable and readonly aliases to the same object, leading to accidental mutation.
( 07 )War story

Readonly Array Mutation in Redux Reducer

Senior Frontend EngineerTypeScript 4.9, React 18, Redux Toolkit, Jest

Timeline

  1. 09:15User reports that a todo list item's text is changing unexpectedly after marking it complete.
  2. 09:20I reproduce the bug: toggling a todo changes the text of another todo.
  3. 09:30I check the Redux DevTools; state shows the mutation but no action dispatched for the text change.
  4. 09:45I look at the reducer: `toggleTodo` uses `state.todos.map(todo => ...)` but then calls `todos.sort()` which mutates the original array because `map` returns a new array but `sort` mutates it in place.
  5. 10:00I find that `todos` is declared as `readonly Todo[]` in the state interface, but the reducer's `state` parameter is typed as `Todo[]` (mutable).
  6. 10:15The root cause: the reducer parameter is not readonly, so the array returned from `map` is mutable, and `sort` mutates it.
  7. 10:30Fix: change the reducer parameter to `Readonly<Todo[]>` and use `toSorted()` (or sort a copy).
  8. 10:45Deploy fix; bug resolved.

I was debugging a weird bug where marking a todo as complete would change the text of a different todo. The Redux DevTools showed the state mutation happening inside the reducer, but no action was dispatched for the text change. I was confused because the reducer only handled the toggle action.

I inspected the reducer code. The `toggleTodo` function received the state and returned a new state. However, it called `state.todos.map(...)` to create a new array of todos, then called `.sort()` on that new array. The problem: `Array.prototype.sort()` mutates the array in place. Even though `map` returned a new array, `sort` mutated that array. But that shouldn't affect the original state, right? Wrong.

The real issue was that the state parameter was typed as `Todo[]` (mutable), not `Readonly<Todo[]>`. So when I called `map`, the resulting array was also mutable. But more importantly, the original `state.todos` array was actually the same reference as the array in the store? No, Redux Toolkit uses Immer under the hood, but the type was misleading. Actually, the bug was that after `map`, I assigned the result to a variable, then called `sort` on it, which mutated that variable, but then I returned that variable as the new state. That was fine. The actual bug was that I was sorting the wrong array—I was sorting the original `state.todos`? No, I re-read the code: I did `const updatedTodos = state.todos.map(...); updatedTodos.sort(...);` – that mutates `updatedTodos`, which is fine. But then I remembered: `state.todos` is readonly in the interface, but the reducer parameter is not. So when I called `state.todos.map`, I got a mutable array. But then `sort` on that array? Wait, the bug was that I accidentally sorted the original array because I used `state.todos.sort()` directly? Let me check the actual code... I found that I had written `state.todos.sort(...)` instead of `updatedTodos.sort(...)`. That was the bug. The `state.todos` was readonly in the type, but because the reducer parameter wasn't readonly, TypeScript allowed the mutation. The fix was to use `toSorted()` (ES2023) or sort a copy.

Root cause

The reducer parameter was typed as mutable `Todo[]` instead of `Readonly<Todo[]>`, allowing accidental mutation of the state via `Array.prototype.sort()`.

The fix

Changed the reducer signature to accept `Readonly<Todo[]>` and replaced `.sort()` with `.toSorted()` (or a sorted copy).

The lesson

Always type reducer state as deeply readonly, and prefer non-mutating array methods. TypeScript's readonly is only a compile-time check; ensure runtime safety as well.

( 08 )TypeScript's Readonly is Shallow

`readonly` in TypeScript only applies to the top-level properties. For example, `readonly { a: { b: number } }` prevents reassigning `obj.a` but allows `obj.a.b = 5`. This is a common source of bugs. To enforce deep immutability, use a recursive `DeepReadonly<T>` utility type or use `as const` for literals.

For arrays, `readonly T[]` prevents methods that mutate the array (like `push`, `pop`, `splice`), but you can still assign the array to a mutable variable and then mutate it. The readonly check only applies through the reference type. If you pass a readonly array to a function that expects `T[]`, TypeScript will complain, but you can bypass with a type assertion.

( 09 )Type Assertions and the Escape Hatch

The most common way readonly violations happen is through type assertions: `(obj as any).prop = value` or `(obj as { prop: number }).prop = value`. This tells TypeScript to trust you, but it removes the readonly guard. In production code, avoid `as` except when absolutely necessary (e.g., when dealing with third-party libraries with incorrect types).

Another variant: using `const` assertions (`as const`) on an object literal makes all properties deeply readonly. This is useful for constants but can cause unexpected errors if you later try to mutate them.

( 10 )Readonly in Classes vs Interfaces

In classes, `readonly` properties can only be assigned in the constructor or at declaration. Attempting to assign in a method gives a compile error. However, if you use a getter that returns a mutable reference, you can mutate the underlying data. For example, `get items(): Item[] { return this._items; }` allows callers to push to the array. To prevent this, return `ReadonlyArray<Item>`.

Interfaces with readonly properties can be implemented by classes, but the class must not have setters for those properties. The readonly modifier in an interface does not force the implementing class to enforce it at runtime; it's only a compile-time contract.

( 11 )Runtime Enforcement with Object.freeze

TypeScript's readonly is purely a compile-time construct. To prevent mutation at runtime, use `Object.freeze()` or libraries like `immer`. `Object.freeze` makes an object immutable shallowly; you need recursive freezing for deep immutability. Tools like `deep-freeze` can help.

In Redux, using Immer (via Redux Toolkit) allows you to write mutative code that is translated into immutable updates. However, you still need to type your state as readonly to get compile-time checks. Immer's `Draft` type handles this internally.

Frequently asked questions

Why does TypeScript allow me to assign a readonly array to a mutable variable?

TypeScript's type system is structural, not nominal. A `ReadonlyArray<T>` is assignable to `T[]` if the readonly modifier is not checked. This is a known limitation. To prevent this, use `readonly T[]` consistently and enable `strictFunctionTypes` in tsconfig, which helps catch some cases. But the only way to be safe is to avoid aliasing and use runtime checks.

Does `const` make properties readonly?

No. `const` only prevents reassignment of the variable itself. For objects and arrays, their properties can still be mutated. Use `readonly` or `Object.freeze` for immutability.

How do I make a nested object deeply readonly?

You can define a recursive type: `type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]>; }`. For arrays, use `ReadonlyArray<DeepReadonly<T>>`. Alternatively, use `as const` on literals to infer deeply readonly types.

Can I override readonly at runtime?

Yes, readonly is only a TypeScript compile-time check. At runtime, properties can be mutated unless you use `Object.freeze()` or a Proxy. TypeScript's type system does not enforce runtime behavior.

Why does my readonly array allow `push` in a function?

If the function parameter is typed as `T[]` (mutable), you can pass a readonly array and then mutate it inside the function. TypeScript will not complain if the parameter is not readonly. To prevent this, always type function parameters that should not be mutated as `ReadonlyArray<T>` or `readonly T[]`.