Back to Blog
TypeScriptReadonlyConstImmutability

TypeScript Readonly and Const Assertions

Master TypeScript readonly modifiers and const assertions for immutable type safety.

B
Bootspring Team
Engineering
October 31, 2018
6 min read

TypeScript provides tools for enforcing immutability at the type level. Here's how to use them effectively.

Readonly Properties#

1// Readonly property modifier 2interface User { 3 readonly id: number; 4 name: string; 5 email: string; 6} 7 8const user: User = { 9 id: 1, 10 name: 'John', 11 email: 'john@example.com' 12}; 13 14user.name = 'Jane'; // OK 15user.id = 2; // Error: Cannot assign to 'id' 16 17// Class readonly properties 18class Config { 19 readonly apiUrl: string; 20 readonly maxRetries: number; 21 22 constructor(url: string, retries: number) { 23 this.apiUrl = url; 24 this.maxRetries = retries; 25 } 26} 27 28const config = new Config('https://api.example.com', 3); 29config.apiUrl = 'new-url'; // Error: Cannot assign to 'apiUrl'

Readonly<T> Utility Type#

1// Make all properties readonly 2interface MutableUser { 3 id: number; 4 name: string; 5 settings: { 6 theme: string; 7 language: string; 8 }; 9} 10 11type ReadonlyUser = Readonly<MutableUser>; 12 13const user: ReadonlyUser = { 14 id: 1, 15 name: 'John', 16 settings: { theme: 'dark', language: 'en' } 17}; 18 19user.name = 'Jane'; // Error 20user.settings = {}; // Error 21user.settings.theme = 'light'; // OK! Shallow readonly 22 23// Deep readonly 24type DeepReadonly<T> = { 25 readonly [P in keyof T]: T[P] extends object 26 ? DeepReadonly<T[P]> 27 : T[P]; 28}; 29 30type FullyReadonlyUser = DeepReadonly<MutableUser>;

ReadonlyArray<T>#

1// Readonly array 2const numbers: ReadonlyArray<number> = [1, 2, 3]; 3// or: readonly number[] 4 5numbers.push(4); // Error: Property 'push' does not exist 6numbers[0] = 10; // Error: Index signature only permits reading 7numbers.length = 0; // Error 8 9// Allowed operations 10const first = numbers[0]; // OK 11const doubled = numbers.map(n => n * 2); // OK (returns new array) 12const filtered = numbers.filter(n => n > 1); // OK 13 14// Function parameter 15function processItems(items: readonly string[]) { 16 // Can't modify items 17 items.push('new'); // Error 18 return items.join(', '); // OK 19}

Const Assertions#

1// as const makes literal types 2const point = { x: 10, y: 20 } as const; 3// Type: { readonly x: 10; readonly y: 20; } 4 5point.x = 15; // Error: Cannot assign to 'x' 6 7// Array becomes readonly tuple 8const colors = ['red', 'green', 'blue'] as const; 9// Type: readonly ['red', 'green', 'blue'] 10 11colors.push('yellow'); // Error 12colors[0] = 'orange'; // Error 13 14// Literal types preserved 15type Colors = typeof colors[number]; 16// 'red' | 'green' | 'blue' 17 18// Without as const 19const colorsNormal = ['red', 'green', 'blue']; 20// Type: string[]

Object Literals with as const#

1// Config object 2const config = { 3 api: { 4 baseUrl: 'https://api.example.com', 5 timeout: 5000, 6 }, 7 features: { 8 darkMode: true, 9 notifications: false, 10 }, 11} as const; 12 13// All properties are readonly and literal types 14type Config = typeof config; 15// { 16// readonly api: { 17// readonly baseUrl: "https://api.example.com"; 18// readonly timeout: 5000; 19// }; 20// readonly features: { 21// readonly darkMode: true; 22// readonly notifications: false; 23// }; 24// } 25 26// Extract literal values 27type BaseUrl = typeof config.api.baseUrl; 28// "https://api.example.com"

Enum-like Constants#

1// Instead of enum, use as const 2const Status = { 3 Pending: 'pending', 4 Active: 'active', 5 Completed: 'completed', 6} as const; 7 8type Status = typeof Status[keyof typeof Status]; 9// 'pending' | 'active' | 'completed' 10 11function updateStatus(status: Status) { 12 console.log(status); 13} 14 15updateStatus(Status.Active); // OK 16updateStatus('pending'); // OK 17updateStatus('invalid'); // Error 18 19// With numeric values 20const HttpStatus = { 21 Ok: 200, 22 NotFound: 404, 23 ServerError: 500, 24} as const; 25 26type HttpStatus = typeof HttpStatus[keyof typeof HttpStatus]; 27// 200 | 404 | 500

Tuple Types#

1// Regular array 2const arr = [1, 'hello']; 3// Type: (string | number)[] 4 5// Const assertion creates tuple 6const tuple = [1, 'hello'] as const; 7// Type: readonly [1, 'hello'] 8 9// Explicit tuple type 10const point: [number, number] = [10, 20]; 11point[0] = 15; // OK, not readonly 12 13// Readonly tuple 14const readonlyPoint: readonly [number, number] = [10, 20]; 15readonlyPoint[0] = 15; // Error 16 17// Function returning tuple 18function getCoordinates(): readonly [number, number] { 19 return [10, 20] as const; 20}

Readonly in Functions#

1// Readonly parameter 2function processUsers(users: readonly User[]) { 3 // Can't modify the array 4 users.push(newUser); // Error 5 users.sort(); // Error 6 7 // Can read and create new arrays 8 return users.filter(u => u.active); 9} 10 11// Return readonly 12function getConfig(): Readonly<Config> { 13 return { 14 apiKey: 'secret', 15 timeout: 5000, 16 }; 17} 18 19const config = getConfig(); 20config.apiKey = 'new'; // Error 21 22// Generic readonly function 23function freeze<T>(obj: T): Readonly<T> { 24 return Object.freeze(obj); 25}

Satisfies with as const#

1// satisfies + as const for type checking with literals 2const routes = { 3 home: '/', 4 about: '/about', 5 users: '/users', 6} as const satisfies Record<string, string>; 7 8// Type preserves literal strings 9type Route = typeof routes[keyof typeof routes]; 10// '/' | '/about' | '/users' 11 12// Error if value doesn't match 13const badRoutes = { 14 home: '/', 15 about: 123, // Error: Type 'number' is not assignable to 'string' 16} as const satisfies Record<string, string>;

Readonly Maps and Sets#

1// ReadonlyMap 2const userMap: ReadonlyMap<string, User> = new Map([ 3 ['1', { id: 1, name: 'John' }], 4 ['2', { id: 2, name: 'Jane' }], 5]); 6 7userMap.get('1'); // OK 8userMap.set('3', user); // Error: Property 'set' does not exist 9 10// ReadonlySet 11const allowedRoles: ReadonlySet<string> = new Set([ 12 'admin', 13 'user', 14 'guest', 15]); 16 17allowedRoles.has('admin'); // OK 18allowedRoles.add('new'); // Error

Mutable from Readonly#

1// Remove readonly modifier 2type Mutable<T> = { 3 -readonly [P in keyof T]: T[P]; 4}; 5 6interface ReadonlyUser { 7 readonly id: number; 8 readonly name: string; 9} 10 11type MutableUser = Mutable<ReadonlyUser>; 12// { id: number; name: string; } 13 14// Mutable array 15type MutableArray<T> = T extends readonly (infer U)[] 16 ? U[] 17 : T; 18 19type Numbers = MutableArray<readonly number[]>; 20// number[]

Patterns and Use Cases#

1// Immutable state 2interface State { 3 readonly users: readonly User[]; 4 readonly loading: boolean; 5 readonly error: string | null; 6} 7 8function reducer(state: State, action: Action): State { 9 switch (action.type) { 10 case 'ADD_USER': 11 return { 12 ...state, 13 users: [...state.users, action.payload], 14 }; 15 default: 16 return state; 17 } 18} 19 20// Builder pattern with readonly result 21class ConfigBuilder { 22 private config: Partial<Config> = {}; 23 24 setApiUrl(url: string): this { 25 this.config.apiUrl = url; 26 return this; 27 } 28 29 setTimeout(timeout: number): this { 30 this.config.timeout = timeout; 31 return this; 32 } 33 34 build(): Readonly<Config> { 35 return this.config as Config; 36 } 37} 38 39// Freeze deep objects 40function deepFreeze<T extends object>(obj: T): DeepReadonly<T> { 41 Object.keys(obj).forEach(key => { 42 const value = (obj as any)[key]; 43 if (typeof value === 'object' && value !== null) { 44 deepFreeze(value); 45 } 46 }); 47 return Object.freeze(obj) as DeepReadonly<T>; 48}

Best Practices#

When to Use Readonly: ✓ Configuration objects ✓ Function parameters you won't modify ✓ Redux/state management ✓ Public API return types When to Use as const: ✓ Object literal constants ✓ Array constants (enum-like) ✓ Preserving literal types ✓ Tuple creation Patterns: ✓ Readonly for interfaces ✓ as const for values ✓ DeepReadonly for nested ✓ Mutable when needed Avoid: ✗ Readonly on primitives (unnecessary) ✗ Over-using DeepReadonly ✗ Forgetting shallow nature ✗ Mixing mutable and readonly

Conclusion#

TypeScript's readonly modifiers and const assertions provide compile-time immutability guarantees. Use readonly for interface properties, Readonly<T> for entire types, and as const for literal value preservation. Remember that readonly is shallow by default - use DeepReadonly for nested immutability. These tools help prevent accidental mutations and make code intent clearer.

Share this article

Help spread the word about Bootspring