The Problem: Why TypeScript Allows Mutating Readonly Objects

In TypeScript, type compatibility is governed by structural typing. This means that as long as the shapes of two types match, they are considered compatible. Surprisingly, this applies to mutability modifiers as well: a Readonly<T> object is structurally compatible with a mutable T object.

Consider the following common scenario:

class Test {
  id = 22;
}

function mutateTest(t: Test) {
  t.id = 23;
}

function mutateTestThrough(t: Readonly<Test>) {
  mutateTest(t); // This does NOT fail in standard TypeScript!
}

Because { readonly id: number } is structurally assignable to { id: number }, TypeScript does not raise an error when you pass a readonly object to a function expecting a mutable one. This can lead to silent runtime bugs where immutable state is accidentally mutated.

The Solution: Enforcing Strict Mutability Checks

To prevent a Readonly type from being passed into a mutable parameter, we need to create a compile-time check that detects whether a type has readonly modifiers. We can achieve this using a well-known TypeScript trick: the IfEquals utility.

Step 1: Define the Type Utilities

We can use a conditional type trick to compare two types strictly, including their modifiers (like readonly):

// Checks if two types are strictly identical, including modifiers
type IfEquals<X, Y, A, B> = 
  (<T>() => T extends X ? 1 : 2) extends 
  (<T>() => T extends Y ? 1 : 2) ? A : B;

// Determines if a type is writable (not readonly)
type IsWritable<T> = IfEquals<T, Readonly<T>, false, true>;

// Returns the type T if it is writable; otherwise returns 'never'
type ValidateWritable<T> = IsWritable<T> extends true ? T : never;

Step 2: Apply the Constraint to Your Function

Now, we can update the signature of mutateTest using a generic parameter constrained by our ValidateWritable helper:

class Test {
  id = 22;
}

// The parameter 't' is constrained to be strictly writable
function mutateTest<T extends Test>(t: T & ValidateWritable<T>) {
  t.id = 23;
}

function mutateTestThrough(t: Readonly<Test>) {
  // @ts-expect-error: Argument of type 'Readonly<Test>' is not assignable to 'never'
  mutateTest(t); // This now correctly FAILS!
}

// This works perfectly because the instance is mutable
mutateTest(new Test());

How It Works

  • IfEquals Magic: TypeScript's compiler treats the internal representation of deferred type checks differently when modifiers like readonly are present. The <T>() => T extends X... trick forces the compiler to perform a strict identity check instead of a structural assignability check.
  • Intersection with never: If t is determined to be readonly, ValidateWritable<T> resolves to never. Intersecting any type with never results in never, preventing any argument from being successfully passed to the function.

Conclusion

By leveraging strict type-equality checks, you can bypass TypeScript's default structural permissiveness regarding mutability. This ensures your read-only data structures remain truly immutable and protected from accidental side-effects across your codebase.