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
readonlymodifier provides compile-time immutability guarantees but doesn’t prevent runtime mutations—it’s a development tool, not a runtime enforcement mechanism. - The
readonlymodifier 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
DeepReadonlyfor 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.