Right, let’s talk about one of those TypeScript gotchas that feels like the language is politely handing you a lit firecracker. You’ve probably slapped a readonly modifier on an array or object, feeling good about yourself for writing safe, immutable code. Good for you. Then, you do something perfectly reasonable like spreading it into a new object… and everything explodes. Or, more accurately, it doesn’t explode, and that’s the whole problem.

The issue is that readonly is a lie. A beautiful, compile-time lie. At runtime, JavaScript couldn’t care less about your purity goals. TypeScript’s readonly is just a type-level assertion that says, “I, the type system, promise not to mutate this.” It doesn’t actually do anything to the object itself. It’s like putting a “Wet Paint” sign on a cannon; the sign isn’t what stops you from touching it.

So, what happens when you spread a readonly thing into a mutable thing? TypeScript gets nervous. Let me show you.

The Spreading Contagion

Imagine you have a nice, constant, read-only array of sacred values.

const sacredValues: readonly number[] = [1, 2, 3];

Now, you want to create a new array that adds a fourth value. You might try this:

const newArray = [...sacredValues, 4]; // This is fine.

No problem. newArray is happily inferred as number[]. But let’s say you want to use this new array in a context that expects a mutable array later. You’re golden.

The trouble starts when you have a readonly object and you want to spread it into a new object to add a property.

interface Config {
  readonly host: string;
  readonly port: number;
}

const initialConfig: Config = { host: 'localhost', port: 8080 };

// You want to create a new config for testing that overrides the host.
const testConfig = {
  ...initialConfig, // Spread the readonly object
  host: 'test-server' // Override one property
};

What type is testConfig? Go on, guess.

If you said { host: string; port: number }, you’d be logical. But TypeScript is often not logical; it’s pedantic. The type it infers is actually { readonly host: string; readonly port: number; } & { host: string }.

Wait, what? That’s a intersection type where one part says host is readonly string and the other says it’s string. This is a mess. In practice, TypeScript will often simplify this, but the point is: the readonly modifier from the spread source can “contaminate” the resulting object’s type in confusing ways. It often results in a type that is itself read-only, which is probably not what you wanted if you’re explicitly trying to mutate parts of it later.

Readonly<T> is a Deep Promise (And That’s the Problem)

Here’s where the designers really committed a choice. The Readonly<T> utility type, and by extension the readonly modifier on object properties, is shallow. It only applies to the top level. This is a classic example of a rough edge. It makes sense for performance and simplicity, but it’s a massive foot-cannon.

interface UserProfile {
  readonly id: number;
  details: {
    name: string;
    email: string;
  };
}

const user: Readonly<UserProfile> = {
  id: 123,
  details: {
    name: 'Alice',
    email: 'alice@example.com'
  }
};

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

// TOTALLY FINE. Wait, what?
user.details.name = 'Bob';

See that? We marked the whole user as Readonly, but it only protected id. The details object is still wide open for mutation. This is why you often see libraries like Zod output deeply readonly types, because they do the extra work to make everything safe. Vanilla TypeScript won’t save you here. You’d need a recursive DeepReadonly<T> type, which is possible to write but isn’t provided out of the box because it can have performance implications on very deep types.

How to Actually Win This Fight

So, what’s the best practice? Be intentional. If you’re spreading a readonly source and you intend for the result to be mutable, you need to tell TypeScript. You do this by asserting the type you actually want.

const testConfig = {
  ...initialConfig,
  host: 'test-server'
} as Config; // "I know what I'm doing, just treat this as a Config"

// Or be more explicit and safer by defining the type first:
const testConfig: Config = {
  ...initialConfig,
  host: 'test-server'
};

For the deep-readonly problem, the solution is awareness. Don’t assume Readonly<T> is a silver bullet. If you need true deep immutability, you either need to use a library that provides it, freeze the object yourself with Object.freeze (though that’s also shallow at runtime!), or structure your code to avoid mutation altogether by always creating new objects.

The key insight is that readonly is a contract between you and the type checker, not a runtime feature. You have to understand its limitations, or you’ll end up with a false sense of security—which is far more dangerous than knowing you’re writing unsafe code from the outset.