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 outsideReadonlyArray#
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; // ErrorConst 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'; // OKImmutable 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.