Back to Blog
TypeScriptImmutabilityReadonlyTypes

TypeScript Readonly and Immutability

Master immutability in TypeScript. From readonly to const assertions to deep immutability.

B
Bootspring Team
Engineering
September 2, 2020
7 min read

Immutability prevents accidental mutations. Here's how TypeScript helps enforce it.

Readonly Properties#

1// Readonly property 2interface User { 3 readonly id: number; 4 name: string; 5 email: string; 6} 7 8const user: User = { id: 1, name: 'Alice', email: 'alice@example.com' }; 9user.name = 'Bob'; // OK 10user.id = 2; // Error: Cannot assign to 'id' because it is read-only 11 12// Readonly in class 13class Config { 14 readonly apiUrl: string; 15 readonly timeout: number; 16 17 constructor(apiUrl: string, timeout: number) { 18 this.apiUrl = apiUrl; 19 this.timeout = timeout; 20 } 21} 22 23const config = new Config('https://api.example.com', 5000); 24config.apiUrl = 'other'; // Error: Cannot assign to 'apiUrl' 25 26// Readonly with initialization 27class Service { 28 readonly createdAt = new Date(); // Initialized at declaration 29}

Readonly Type#

1// Readonly<T> utility type 2interface Point { 3 x: number; 4 y: number; 5} 6 7type ReadonlyPoint = Readonly<Point>; 8// { readonly x: number; readonly y: number; } 9 10const point: ReadonlyPoint = { x: 10, y: 20 }; 11point.x = 30; // Error 12 13// Make function parameter readonly 14function processPoint(point: Readonly<Point>) { 15 // Cannot modify point inside function 16 point.x = 0; // Error 17 return point.x + point.y; 18} 19 20// Original is still mutable 21const mutablePoint: Point = { x: 10, y: 20 }; 22processPoint(mutablePoint); // OK - treated as readonly inside 23mutablePoint.x = 30; // OK - still mutable outside

ReadonlyArray#

1// ReadonlyArray type 2const numbers: ReadonlyArray<number> = [1, 2, 3, 4, 5]; 3numbers.push(6); // Error: Property 'push' does not exist 4numbers[0] = 10; // Error: Index signature only permits reading 5 6// Alternative syntax 7const items: readonly number[] = [1, 2, 3]; 8items.pop(); // Error 9 10// Array methods that return new arrays still work 11const doubled = numbers.map(n => n * 2); // OK - returns new array 12const filtered = numbers.filter(n => n > 2); // OK 13 14// Function parameters 15function sum(numbers: readonly number[]): number { 16 return numbers.reduce((a, b) => a + b, 0); 17} 18 19// Readonly tuple 20const point: readonly [number, number] = [10, 20]; 21point[0] = 30; // Error

Const Assertions#

1// as const makes everything deeply readonly 2const config = { 3 api: { 4 url: 'https://api.example.com', 5 timeout: 5000, 6 }, 7 features: ['auth', 'logging'], 8} as const; 9 10// Type is: 11// { 12// readonly api: { 13// readonly url: "https://api.example.com"; 14// readonly timeout: 5000; 15// }; 16// readonly features: readonly ["auth", "logging"]; 17// } 18 19config.api.url = 'other'; // Error 20config.features.push('new'); // Error 21 22// Literal types preserved 23type Url = typeof config.api.url; 24// "https://api.example.com" (not string) 25 26// Array as const 27const colors = ['red', 'green', 'blue'] as const; 28type Color = typeof colors[number]; 29// "red" | "green" | "blue" 30 31// Object keys as union 32const STATUS = { 33 PENDING: 'pending', 34 ACTIVE: 'active', 35 COMPLETED: 'completed', 36} as const; 37 38type StatusKey = keyof typeof STATUS; 39// "PENDING" | "ACTIVE" | "COMPLETED" 40 41type StatusValue = typeof STATUS[StatusKey]; 42// "pending" | "active" | "completed"

Deep Readonly#

1// Built-in Readonly is shallow 2interface Nested { 3 outer: { 4 inner: { 5 value: number; 6 }; 7 }; 8} 9 10type ShallowReadonly = Readonly<Nested>; 11const obj: ShallowReadonly = { 12 outer: { inner: { value: 42 } }, 13}; 14 15obj.outer = {}; // Error - outer is readonly 16obj.outer.inner.value = 100; // OK! - inner is still mutable 17 18// Deep readonly type 19type DeepReadonly<T> = T extends (infer R)[] 20 ? DeepReadonlyArray<R> 21 : T extends Function 22 ? T 23 : T extends object 24 ? DeepReadonlyObject<T> 25 : T; 26 27type DeepReadonlyArray<T> = ReadonlyArray<DeepReadonly<T>>; 28 29type DeepReadonlyObject<T> = { 30 readonly [P in keyof T]: DeepReadonly<T[P]>; 31}; 32 33// Usage 34type ImmutableNested = DeepReadonly<Nested>; 35const immutable: ImmutableNested = { 36 outer: { inner: { value: 42 } }, 37}; 38 39immutable.outer.inner.value = 100; // Error now!

Immutable Patterns#

1// Immutable update pattern 2interface State { 3 user: { 4 name: string; 5 settings: { 6 theme: string; 7 notifications: boolean; 8 }; 9 }; 10 items: string[]; 11} 12 13function updateTheme(state: Readonly<State>, theme: string): State { 14 return { 15 ...state, 16 user: { 17 ...state.user, 18 settings: { 19 ...state.user.settings, 20 theme, 21 }, 22 }, 23 }; 24} 25 26// Immutable array operations 27function addItem(items: readonly string[], item: string): string[] { 28 return [...items, item]; 29} 30 31function removeItem(items: readonly string[], index: number): string[] { 32 return [...items.slice(0, index), ...items.slice(index + 1)]; 33} 34 35function updateItem( 36 items: readonly string[], 37 index: number, 38 value: string 39): string[] { 40 return items.map((item, i) => (i === index ? value : item)); 41}

Readonly vs Immutable#

1// Readonly is a compile-time check only 2const arr: readonly number[] = [1, 2, 3]; 3(arr as number[]).push(4); // Bypasses type check at runtime! 4 5// True immutability with Object.freeze 6const frozen = Object.freeze({ x: 10, y: 20 }); 7frozen.x = 30; // Runtime error in strict mode (silently fails otherwise) 8 9// Deep freeze 10function deepFreeze<T extends object>(obj: T): Readonly<T> { 11 Object.keys(obj).forEach(key => { 12 const value = (obj as any)[key]; 13 if (typeof value === 'object' && value !== null) { 14 deepFreeze(value); 15 } 16 }); 17 return Object.freeze(obj); 18} 19 20// Combine TypeScript + runtime 21function createImmutable<T extends object>(obj: T): DeepReadonly<T> { 22 return deepFreeze(obj) as DeepReadonly<T>; 23}

Readonly in Functions#

1// Readonly parameters prevent mutation 2function processArray(arr: readonly number[]): number { 3 // Can read but not modify 4 return arr.reduce((sum, n) => sum + n, 0); 5} 6 7// Readonly return type signals immutability 8function getConfig(): Readonly<Config> { 9 return { 10 apiUrl: 'https://api.example.com', 11 timeout: 5000, 12 }; 13} 14 15// Generic with readonly constraint 16function first<T>(arr: readonly T[]): T | undefined { 17 return arr[0]; 18} 19 20// Readonly record 21type Cache = Readonly<Record<string, unknown>>; 22 23function getFromCache(cache: Cache, key: string): unknown { 24 return cache[key]; 25}

Mutable vs Readonly Types#

1// Sometimes need both versions 2interface MutableUser { 3 id: number; 4 name: string; 5 email: string; 6} 7 8type User = Readonly<MutableUser>; 9 10// Or define readonly first 11interface User { 12 readonly id: number; 13 readonly name: string; 14 readonly email: string; 15} 16 17type MutableUser = { 18 -readonly [K in keyof User]: User[K]; 19}; 20 21// Utility type for mutable 22type Mutable<T> = { 23 -readonly [P in keyof T]: T[P]; 24}; 25 26// Usage 27const user: User = { id: 1, name: 'Alice', email: 'alice@example.com' }; 28const mutable: Mutable<User> = { ...user }; 29mutable.name = 'Bob'; // OK

Immutable Libraries#

1// With Immer 2import produce from 'immer'; 3 4interface State { 5 users: User[]; 6 selectedId: number | null; 7} 8 9const nextState = produce(state, draft => { 10 // Mutate draft directly 11 draft.users.push({ id: 3, name: 'Charlie' }); 12 draft.selectedId = 3; 13}); 14// state is unchanged, nextState is new immutable state 15 16// With Immutable.js 17import { Map, List } from 'immutable'; 18 19const map = Map({ name: 'Alice', age: 30 }); 20const newMap = map.set('age', 31); 21// map is unchanged, newMap is new 22 23// TypeScript integration 24interface AppState { 25 users: List<User>; 26 config: Map<string, unknown>; 27}

Best Practices#

Design: ✓ Default to readonly for data ✓ Use const assertions for constants ✓ Make function params readonly ✓ Return readonly from getters Patterns: ✓ Spread for immutable updates ✓ map/filter/reduce for arrays ✓ Use Immer for complex updates ✓ Deep readonly for nested data Performance: ✓ Readonly has no runtime cost ✓ Object.freeze has minimal cost ✓ Structural sharing in libraries ✓ Avoid deep cloning when possible Avoid: ✗ Type assertions to bypass readonly ✗ Shallow readonly for nested data ✗ Mutating "readonly" at runtime ✗ Over-freezing in hot paths

Conclusion#

TypeScript's readonly features catch mutation errors at compile time. Use Readonly<T> for shallow protection, deep readonly types for nested data, and const assertions for literal types. Combine with runtime immutability (Object.freeze or libraries) when true immutability is required.

Share this article

Help spread the word about Bootspring