loke.dev
Header image for Why Does Your 'Readonly' TypeScript Property Still Mutate at Runtime?

Why Does Your 'Readonly' TypeScript Property Still Mutate at Runtime?

We dissect the architectural gap between compile-time immutability and the underlying JavaScript reality that leaves your 'protected' data vulnerable to reference aliasing.

· 4 min read

You’ve spent all morning carefully defining your interfaces. You’ve added readonly to every sensitive property, feeling confident that your data is now shielded from accidental mutations. You run your code, and suddenly, a value changes. You stare at the screen. TypeScript didn't complain, yet the data is different.

Welcome to the "TypeScript Illusion." Let's look at why this happens and how to actually stop it.

interface UserProfile {
  readonly id: string;
  readonly settings: {
    theme: 'light' | 'dark';
  };
}

const user: UserProfile = {
  id: 'user-123',
  settings: { theme: 'light' }
};

// Error: Cannot assign to 'id' because it is a read-only property.
// user.id = 'user-456'; 

// Wait... this works perfectly fine?
user.settings.theme = 'dark'; 

console.log(user.settings.theme); // "dark"

The "Ghost" in the Machine

The first thing to internalize is that TypeScript is a ghost. Once the compiler finishes its job and emits JavaScript, the readonly keyword evaporates. It exists solely to help you catch mistakes during development.

At runtime, your UserProfile is just a plain old JavaScript object. JavaScript doesn’t know what readonly is (at least not in the way TS defines it). If a piece of code — say, a third-party library or a legacy function — gets a hold of your object, it will mutate it without a second thought.

The Aliasing Trap

Reference aliasing is the most common way readonly gets bypassed. TypeScript’s type checking is localized to the specific variable you are using. If you have two pointers to the same object, and one is readonly while the other is mutable, the mutable one wins at runtime.

I’ve fallen into this trap more times than I’d like to admit:

interface MutableAccount {
  balance: number;
}

interface ReadonlyAccount {
  readonly balance: number;
}

const myAccount: MutableAccount = { balance: 100 };

// We "cast" or assign it to a readonly version
const safeView: ReadonlyAccount = myAccount;

// This would fail:
// safeView.balance = 500;

// But this works, and it affects safeView!
myAccount.balance = 0;

console.log(safeView.balance); // 0

Because safeView and myAccount point to the same spot in memory, the readonly modifier on safeView is essentially a polite suggestion that you, the developer, shouldn't touch it. It does nothing to prevent the underlying data from shifting beneath your feet.

Shallow-Only Protection

As seen in the first example, readonly is shallow. When you mark a property as readonly, TypeScript only protects that specific property's assignment. If that property is an object or an array, the *contents* of that object remain fair game.

If you want deep immutability, you have to work for it. You can use the Readonly<T> utility type, but even that is shallow. For deep protection, you'd need a recursive type alias, which gets messy fast.

The "As Const" Power Move

If you’re working with literal data and want the compiler to be extremely strict, as const is your best friend. It transforms every property into a readonly version and even narrows types to their literal values.

const CONFIG = {
  api: 'https://api.example.com',
  retries: 3,
  endpoints: ['auth', 'users']
} as const;

// All of these will now trigger compiler errors:
// CONFIG.api = '...';
// CONFIG.retries = 5;
// CONFIG.endpoints.push('settings');

This is much stronger than a simple interface because it applies to the entire structure recursively. However, it only works for object literals you define right there—it won't help you with data coming back from a fetch request.

Forcing Runtime Reality with Object.freeze

When you absolutely, positively need to ensure a value doesn't change at runtime, you have to leave TypeScript land and use a JavaScript hammer: Object.freeze().

const systemStatus = Object.freeze({
  locked: true,
  code: 101
});

// In 'use strict' mode, this throws a TypeError.
// In non-strict mode, it just silently fails.
systemStatus.locked = false; 

console.log(systemStatus.locked); // true

The beauty here is that TypeScript is smart enough to recognize Object.freeze(). When you wrap an object in it, TS automatically treats the properties as readonly.

The catch? Object.freeze() is also shallow. If you have a nested object, the inner object can still be mutated unless you freeze it too.

How to Stay Sane

Don't throw away readonly just because it isn't a silver bullet. It's still incredibly useful for defining intent and catching bugs. To make it more effective, I follow these three rules:

1. Treat incoming data as hostile. If you receive an object and need it to stay static, clone it or freeze it immediately.
2. Use `ReadonlyArray<T>`. Standard arrays are notoriously easy to mutate (looking at you, .sort() and .push()). Using ReadonlyArray prevents these methods from even appearing in your autocomplete.
3. Embrace Functional Patterns. Instead of trying to lock down your objects, get into the habit of returning *new* objects. Use the spread operator (...) to create copies with updates. This bypasses the mutation problem entirely by making mutations impossible by design.

TypeScript's readonly is a contract between you and the compiler. It's a great contract, but remember: the browser didn't sign it.