Readonly & Immutability
readonly properties, Readonly<T>, ReadonlyArray, and as const for preventing accidental mutations. You'll hit this when a function modifies an array or object it was only supposed to read.
interface Config {
apiUrl: string;
timeout: number;
}
const config: Config = {
apiUrl: "https://api.example.com",
timeout: 5000,
};
// Oops, accidentally mutated config
config.timeout = -1;
// No error, invalid stateinterface Config {
apiUrl: string;
timeout: number;
}
const config: Config = {
apiUrl: "https://api.example.com",
timeout: 5000,
};
// Oops, accidentally mutated config
config.timeout = -1;
// No error, invalid stateinterface Config {
readonly apiUrl: string;
readonly timeout: number;
}
const config: Config = {
apiUrl: "https://api.example.com",
timeout: 5000,
};
// config.timeout = -1;
// Error: Cannot assign to 'timeout'
// because it is a read-only propertyinterface Config {
readonly apiUrl: string;
readonly timeout: number;
}
const config: Config = {
apiUrl: "https://api.example.com",
timeout: 5000,
};
// config.timeout = -1;
// Error: Cannot assign to 'timeout'
// because it is a read-only propertyWithout readonly, any code with a reference to the config object can change its properties. A single accidental assignment like config.timeout = -1 creates an invalid state that affects the entire application. The bug may surface far from where the mutation happened.
The readonly modifier on interface properties prevents reassignment after initialization. This catches accidental mutations at compile time. It is especially valuable for configuration objects that should be set once and never changed.
interface User {
name: string;
email: string;
role: "admin" | "user";
}
function displayUser(user: User) {
// Function accidentally mutates input
user.role = "admin";
// Caller's object is now corrupted
console.log(user.name);
}interface User {
name: string;
email: string;
role: "admin" | "user";
}
function displayUser(user: User) {
// Function accidentally mutates input
user.role = "admin";
// Caller's object is now corrupted
console.log(user.name);
}interface User {
name: string;
email: string;
role: "admin" | "user";
}
function displayUser(user: Readonly<User>) {
// user.role = "admin";
// Error: Cannot assign to 'role'
console.log(user.name); // OK to read
}
// Original User type is still mutable
// where mutation is intended
function promoteUser(user: User) {
user.role = "admin"; // OK
}interface User {
name: string;
email: string;
role: "admin" | "user";
}
function displayUser(user: Readonly<User>) {
// user.role = "admin";
// Error: Cannot assign to 'role'
console.log(user.name); // OK to read
}
// Original User type is still mutable
// where mutation is intended
function promoteUser(user: User) {
user.role = "admin"; // OK
}Passing a mutable type to a function gives it permission to modify the object. Since objects are passed by reference, the caller's data is corrupted. This is a common source of bugs in functions that are supposed to only read their input.
Readonly<T> makes all properties of T readonly without changing the original type. Applying it to function parameters signals that the function only reads the data. Functions that need to mutate can still use the original mutable type.
function getTopScores(
scores: number[]
): number[] {
// Sort mutates the original array!
scores.sort((a, b) => b - a);
return scores.slice(0, 3);
}
const allScores = [42, 99, 17, 88, 73];
const top3 = getTopScores(allScores);
// allScores is now [99, 88, 73, 42, 17]
// Original order is destroyedfunction getTopScores(
scores: number[]
): number[] {
// Sort mutates the original array!
scores.sort((a, b) => b - a);
return scores.slice(0, 3);
}
const allScores = [42, 99, 17, 88, 73];
const top3 = getTopScores(allScores);
// allScores is now [99, 88, 73, 42, 17]
// Original order is destroyedfunction getTopScores(
scores: readonly number[]
): number[] {
// scores.sort(...);
// Error: Property 'sort' does not exist
// on type 'readonly number[]'
// Must create a new array first:
return [...scores]
.sort((a, b) => b - a)
.slice(0, 3);
}
const allScores = [42, 99, 17, 88, 73];
const top3 = getTopScores(allScores);
// allScores is still [42, 99, 17, 88, 73]function getTopScores(
scores: readonly number[]
): number[] {
// scores.sort(...);
// Error: Property 'sort' does not exist
// on type 'readonly number[]'
// Must create a new array first:
return [...scores]
.sort((a, b) => b - a)
.slice(0, 3);
}
const allScores = [42, 99, 17, 88, 73];
const top3 = getTopScores(allScores);
// allScores is still [42, 99, 17, 88, 73]Array.sort() mutates the array in place and returns the same reference. When a function sorts its input array, the caller's original data is permanently reordered. This is one of the most common mutation bugs in JavaScript, and readonly arrays catch it at compile time.
readonly number[] (or ReadonlyArray<number>) removes mutating methods like sort, push, pop, and splice from the type. This forces you to copy the array before sorting, preventing accidental mutation of the caller's data. The spread operator creates a shallow copy that is safe to sort.
const COLORS = ["red", "green", "blue"];
// Type: string[]
type Color = typeof COLORS[number];
// Type: string (not useful)
function setColor(color: Color) {
// Accepts any string, not just
// "red" | "green" | "blue"
}
setColor("banana"); // No errorconst COLORS = ["red", "green", "blue"];
// Type: string[]
type Color = typeof COLORS[number];
// Type: string (not useful)
function setColor(color: Color) {
// Accepts any string, not just
// "red" | "green" | "blue"
}
setColor("banana"); // No errorconst COLORS = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]
type Color = typeof COLORS[number];
// Type: "red" | "green" | "blue"
function setColor(color: Color) {
// Only accepts the three valid colors
}
setColor("red"); // OK
// setColor("banana"); // Errorconst COLORS = ["red", "green", "blue"] as const;
// Type: readonly ["red", "green", "blue"]
type Color = typeof COLORS[number];
// Type: "red" | "green" | "blue"
function setColor(color: Color) {
// Only accepts the three valid colors
}
setColor("red"); // OK
// setColor("banana"); // ErrorWithout as const, TypeScript widens the array to string[]. Extracting a type with typeof COLORS[number] gives string, which accepts any string value. The intended constraint of only three valid colors is lost, and typos like 'banana' pass without errors.
The as const assertion tells TypeScript to infer the narrowest possible type: a readonly tuple of literal strings instead of a mutable array of string. Indexing with [number] extracts a union of the literal types. This keeps the runtime array and the type in sync from a single source of truth.
interface AppState {
readonly user: {
name: string;
preferences: {
theme: "light" | "dark";
};
};
}
const state: AppState = {
user: {
name: "Alice",
preferences: { theme: "light" },
},
};
// state.user = ...; // Error (readonly)
state.user.name = "Bob"; // No error!
state.user.preferences.theme = "dark"; // No error!
// readonly is only one level deepinterface AppState {
readonly user: {
name: string;
preferences: {
theme: "light" | "dark";
};
};
}
const state: AppState = {
user: {
name: "Alice",
preferences: { theme: "light" },
},
};
// state.user = ...; // Error (readonly)
state.user.name = "Bob"; // No error!
state.user.preferences.theme = "dark"; // No error!
// readonly is only one level deeptype DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface AppState {
user: {
name: string;
preferences: {
theme: "light" | "dark";
};
};
}
const state: DeepReadonly<AppState> = {
user: {
name: "Alice",
preferences: { theme: "light" },
},
};
// state.user.name = "Bob"; // Error
// state.user.preferences.theme = "dark";
// Error: Cannot assign to 'theme'type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface AppState {
user: {
name: string;
preferences: {
theme: "light" | "dark";
};
};
}
const state: DeepReadonly<AppState> = {
user: {
name: "Alice",
preferences: { theme: "light" },
},
};
// state.user.name = "Bob"; // Error
// state.user.preferences.theme = "dark";
// Error: Cannot assign to 'theme'Marking only the top-level property as readonly gives a false sense of security. The nested properties are still fully mutable, so code can change user.name or preferences.theme without any compiler warning. For state management, shallow readonly is often insufficient.
Readonly<T> and the readonly modifier only apply to the immediate properties. Nested objects remain mutable. A recursive DeepReadonly type applies readonly at every level by checking if the value is an object and recursively wrapping it. This provides true immutability for the entire object tree.
interface CartItem {
id: string;
quantity: number;
price: number;
}
function calculateTotal(items: CartItem[]) {
let total = 0;
// Accidentally mutating while iterating
for (const item of items) {
item.price = item.price * 1.1; // Add tax
total += item.price * item.quantity;
}
return total;
// Caller's items now have inflated prices
// Calling twice doubles the tax
}interface CartItem {
id: string;
quantity: number;
price: number;
}
function calculateTotal(items: CartItem[]) {
let total = 0;
// Accidentally mutating while iterating
for (const item of items) {
item.price = item.price * 1.1; // Add tax
total += item.price * item.quantity;
}
return total;
// Caller's items now have inflated prices
// Calling twice doubles the tax
}interface CartItem {
id: string;
quantity: number;
price: number;
}
function calculateTotal(
items: ReadonlyArray<Readonly<CartItem>>
) {
let total = 0;
for (const item of items) {
// item.price = item.price * 1.1;
// Error: Cannot assign to 'price'
const priceWithTax = item.price * 1.1;
total += priceWithTax * item.quantity;
}
return total;
// Caller's data is untouched
}interface CartItem {
id: string;
quantity: number;
price: number;
}
function calculateTotal(
items: ReadonlyArray<Readonly<CartItem>>
) {
let total = 0;
for (const item of items) {
// item.price = item.price * 1.1;
// Error: Cannot assign to 'price'
const priceWithTax = item.price * 1.1;
total += priceWithTax * item.quantity;
}
return total;
// Caller's data is untouched
}Mutating input data inside a calculation function is a common bug that produces incorrect results when the function is called more than once. The tax gets applied again to already-taxed prices, compounding with each call. Readonly parameters make this impossible at compile time.
Combining ReadonlyArray (prevents push/pop/splice) with Readonly on each element (prevents property assignment) provides full protection. The function is forced to use local variables for computed values instead of mutating the input. This makes the function pure and safe to call repeatedly.