TypeScript Readonly: Immutable Properties and Arrays

Immutability is a cornerstone of predictable, maintainable code. When data structures can't be modified after creation, you eliminate entire categories of bugs: unexpected side effects, race...

Key Insights

  • TypeScript’s readonly modifier provides compile-time immutability guarantees but doesn’t prevent runtime mutations—it’s a development tool, not a runtime enforcement mechanism.
  • The readonly modifier is shallow by default, meaning nested objects and arrays remain mutable unless you apply recursive readonly types or use immutability libraries.
  • Readonly types improve code predictability by making mutation intent explicit, particularly valuable in Redux state management, React props, and shared configuration objects.

Introduction to Immutability in TypeScript

Immutability is a cornerstone of predictable, maintainable code. When data structures can’t be modified after creation, you eliminate entire categories of bugs: unexpected side effects, race conditions in async code, and the nightmare of tracking down which function mutated shared state.

TypeScript provides several mechanisms for enforcing immutability at compile time. The readonly modifier and Readonly<T> utility type are your first line of defense against accidental mutations. However, it’s critical to understand that these are compile-time only features—they disappear completely in the generated JavaScript.

// Mutable approach - dangerous
interface User {
  id: number;
  name: string;
}

const user: User = { id: 1, name: 'Alice' };
user.name = 'Bob'; // Works, but might be unintended

// Immutable approach - safe
interface ReadonlyUser {
  readonly id: number;
  readonly name: string;
}

const readonlyUser: ReadonlyUser = { id: 1, name: 'Alice' };
readonlyUser.name = 'Bob'; // Error: Cannot assign to 'name' because it is a read-only property

This compile-time checking catches bugs during development, long before they reach production.

The readonly Modifier for Properties

The readonly modifier works on interface properties, type aliases, and class fields. It signals that a property should never be reassigned after initialization.

interface Config {
  readonly apiUrl: string;
  readonly timeout: number;
  retryAttempts: number; // mutable
}

const config: Config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retryAttempts: 3
};

config.retryAttempts = 5; // OK
config.timeout = 10000; // Error: Cannot assign to 'timeout'

In classes, readonly properties must be initialized either at declaration or in the constructor:

class DatabaseConnection {
  readonly host: string;
  readonly port: number;
  readonly maxConnections: number = 100; // initialized at declaration
  
  constructor(host: string, port: number) {
    this.host = host;
    this.port = port;
    // After constructor completes, these become immutable
  }
  
  reconnect() {
    this.host = 'new-host'; // Error: Cannot assign to 'host'
  }
}

A crucial point: readonly prevents reassignment, not mutation. If a readonly property holds an object or array, you can still modify that object’s contents:

interface AppState {
  readonly users: User[];
}

const state: AppState = { users: [] };
state.users = []; // Error: Cannot assign to 'users'
state.users.push({ id: 1, name: 'Alice' }); // OK - mutating the array contents

The Readonly<T> Utility Type

TypeScript includes Readonly<T>, a mapped type that makes all properties of a type readonly. This is incredibly useful when you want to create an immutable version of an existing type without redefining it.

interface Product {
  id: number;
  name: string;
  price: number;
}

type ReadonlyProduct = Readonly<Product>;
// Equivalent to:
// {
//   readonly id: number;
//   readonly name: string;
//   readonly price: number;
// }

function processProduct(product: Readonly<Product>) {
  product.price = 0; // Error: Cannot assign to 'price'
  // Function signature guarantees no mutations
  return product.price * 1.1;
}

Use Readonly<T> in function parameters to communicate that your function won’t modify its inputs. This is self-documenting code that the compiler enforces:

function calculateTotal(items: Readonly<Product[]>): number {
  items.push({ id: 99, name: 'Extra', price: 10 }); // Error
  return items.reduce((sum, item) => sum + item.price, 0);
}

However, Readonly<T> is shallow. Nested objects remain mutable:

interface Order {
  id: number;
  customer: {
    name: string;
    email: string;
  };
}

const order: Readonly<Order> = {
  id: 1,
  customer: { name: 'Alice', email: 'alice@example.com' }
};

order.id = 2; // Error: Cannot assign to 'id'
order.customer.email = 'newemail@example.com'; // OK - nested object is mutable

Readonly Arrays and Tuples

TypeScript offers two syntaxes for readonly arrays: ReadonlyArray<T> and readonly T[]. They’re functionally identical—choose based on your style guide.

const numbers1: ReadonlyArray<number> = [1, 2, 3];
const numbers2: readonly number[] = [1, 2, 3];

numbers1.push(4); // Error: Property 'push' does not exist
numbers2[0] = 10; // Error: Index signature in type 'readonly number[]' only permits reading

Readonly arrays remove mutating methods like push, pop, shift, unshift, splice, and sort. You can still use non-mutating methods like map, filter, slice, and concat:

const original: readonly number[] = [1, 2, 3];
const doubled = original.map(n => n * 2); // OK - returns new array
const sorted = original.sort(); // Error: Property 'sort' does not exist

// Use slice() to create a mutable copy if needed
const mutableCopy = original.slice();
mutableCopy.sort(); // OK

Readonly tuples work similarly:

type Point = readonly [number, number];

const point: Point = [10, 20];
point[0] = 15; // Error: Cannot assign to '0'

function distance(p1: Point, p2: Point): number {
  const dx = p2[0] - p1[0];
  const dy = p2[1] - p1[1];
  return Math.sqrt(dx * dx + dy * dy);
}

Deep Readonly with Recursive Types

For truly immutable nested structures, you need a recursive DeepReadonly type. This uses conditional types and mapped types to apply readonly at every level:

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends (infer U)[]
    ? ReadonlyArray<DeepReadonly<U>>
    : T[P] extends object
    ? DeepReadonly<T[P]>
    : T[P];
};

interface NestedConfig {
  database: {
    host: string;
    credentials: {
      username: string;
      password: string;
    };
  };
  features: string[];
}

type ImmutableConfig = DeepReadonly<NestedConfig>;

const config: ImmutableConfig = {
  database: {
    host: 'localhost',
    credentials: {
      username: 'admin',
      password: 'secret'
    }
  },
  features: ['auth', 'logging']
};

config.database.host = 'remote'; // Error
config.database.credentials.password = 'new'; // Error
config.features.push('metrics'); // Error

This type recursively traverses the structure, making arrays readonly and recursing into objects. It handles most common cases, though very complex types with circular references may need additional handling.

Practical Use Cases and Patterns

Redux State Management

Redux state should never be mutated directly. Readonly types enforce this at compile time:

interface AppState {
  readonly user: Readonly<{
    id: number;
    name: string;
  }> | null;
  readonly items: ReadonlyArray<Readonly<{
    id: number;
    title: string;
  }>>;
}

function reducer(state: AppState, action: Action): AppState {
  state.items.push(newItem); // Error: Property 'push' does not exist
  
  // Correct: return new state
  return {
    ...state,
    items: [...state.items, newItem]
  };
}

Configuration Objects

Application configuration should be immutable after initialization:

const CONFIG = {
  API_URL: 'https://api.example.com',
  TIMEOUT: 5000,
  FEATURES: ['auth', 'analytics']
} as const; // Makes all properties deeply readonly

// Type is: {
//   readonly API_URL: "https://api.example.com";
//   readonly TIMEOUT: 5000;
//   readonly FEATURES: readonly ["auth", "analytics"];
// }

CONFIG.API_URL = 'other'; // Error

React Props

React props should be treated as immutable:

interface Props {
  readonly user: Readonly<User>;
  readonly onUpdate: (user: User) => void;
}

function UserProfile({ user, onUpdate }: Props) {
  user.name = 'Modified'; // Error
  
  // Correct: create new object
  onUpdate({ ...user, name: 'Modified' });
}

Combining with Other Utility Types

type PartialReadonly<T> = Readonly<Partial<T>>;

interface Settings {
  theme: string;
  language: string;
  notifications: boolean;
}

function updateSettings(current: Settings, updates: PartialReadonly<Settings>): Settings {
  updates.theme = 'dark'; // Error: Cannot assign to 'theme'
  return { ...current, ...updates };
}

Performance and Best Practices

Compile-Time Only: Remember that readonly is erased during compilation. The generated JavaScript has no immutability enforcement:

const obj: Readonly<{ value: number }> = { value: 10 };
obj.value = 20; // TypeScript error

// But at runtime (JavaScript):
obj.value = 20; // Works fine

For runtime immutability, use Object.freeze() or libraries like Immer:

const obj = Object.freeze({ value: 10 });
obj.value = 20; // Runtime error in strict mode, silent failure otherwise

// Or use Immer for complex state updates
import produce from 'immer';

const nextState = produce(currentState, draft => {
  draft.items.push(newItem); // Immer handles immutability
});

When to Use Readonly:

  • Function parameters that shouldn’t be modified
  • Redux/state management types
  • Configuration objects
  • API response types where you’re consuming but not modifying data
  • Public API boundaries where you want to prevent consumers from mutating shared state

Common Gotchas:

  • Readonly is shallow—use DeepReadonly for nested structures
  • Type assertions can bypass readonly: (obj as any).prop = value
  • Doesn’t prevent mutations via references without readonly types
  • No runtime cost, but also no runtime protection

TypeScript’s readonly features are powerful tools for building predictable applications. Use them liberally at API boundaries and in state management to catch mutation bugs before they reach production. Combine with runtime immutability libraries when you need guarantees beyond compile-time checking.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.