Prerequisites
- Understanding of interfaces and types π
- Basic TypeScript syntax π
- Object-oriented concepts π»
What you'll learn
- Understand readonly modifier and its benefits π―
- Design immutable data structures ποΈ
- Apply readonly patterns effectively π‘οΈ
- Create type-safe immutable APIs β¨
π― Introduction
Welcome to the fortress of immutability! π In this guide, weβll explore readonly properties in TypeScript, a powerful feature that helps you create immutable, predictable, and bug-resistant code.
Youβll discover how readonly properties are like museum exhibits ποΈ - you can look, but you canβt touch! Whether youβre building state management systems π, designing APIs π, or creating configuration objects βοΈ, understanding readonly properties is essential for writing robust TypeScript applications.
By the end of this tutorial, youβll be confidently designing immutable data structures that prevent accidental mutations and make your code more predictable! Letβs lock it down! πββοΈ
π Understanding Readonly Properties
π€ What are Readonly Properties?
Readonly properties in TypeScript are properties that can only be assigned a value during initialization. After that, they become immutable - any attempt to modify them results in a compile-time error.
Think of readonly properties like:
- ποΈ Museum artifacts: Look but donβt touch
- π Historical documents: Preserved and unchangeable
- π Bank vault contents: Secured and protected
- ποΈ Mountains: Solid, unchanging landmarks
π‘ Why Use Readonly Properties?
Hereβs why developers embrace immutability:
- Predictability π―: Data doesnβt change unexpectedly
- Thread Safety π: No race conditions with immutable data
- Debugging π: Easier to track data flow
- Performance π: Enables optimizations like memoization
Real-world example: Configuration objects π - once your app starts with certain settings, you donβt want them accidentally changed mid-execution!
π§ Basic Syntax and Usage
π Readonly in Interfaces and Types
Letβs start with the fundamentals:
// π’ Company information - should never change after creation
interface Company {
readonly id: string;
readonly name: string;
readonly founded: Date;
readonly taxId: string;
// Mutable properties
employees: number;
revenue: number;
// Nested readonly
readonly headquarters: {
readonly address: string;
readonly city: string;
readonly country: string;
};
}
// β
Creating a company
const techCorp: Company = {
id: 'comp_001',
name: 'TechCorp Inc.',
founded: new Date('2010-01-01'),
taxId: 'TC-12345678',
employees: 100,
revenue: 1000000,
headquarters: {
address: '123 Tech Street',
city: 'San Francisco',
country: 'USA'
}
};
// β
Can modify mutable properties
techCorp.employees = 150;
techCorp.revenue = 1500000;
// β Cannot modify readonly properties
// techCorp.id = 'comp_002'; // Error!
// techCorp.name = 'NewCorp'; // Error!
// techCorp.founded = new Date(); // Error!
// β Cannot modify nested readonly properties
// techCorp.headquarters.city = 'New York'; // Error!
// β οΈ But be careful - readonly is shallow!
// This creates a new object, which is allowed:
// techCorp.headquarters = { ... }; // Would error because headquarters itself is readonly
// π Readonly arrays
interface ProductCatalog {
readonly products: ReadonlyArray<Product>;
readonly categories: readonly string[];
lastUpdated: Date;
}
interface Product {
readonly id: string;
readonly sku: string;
name: string;
price: number;
inStock: boolean;
}
const catalog: ProductCatalog = {
products: [
{ id: '1', sku: 'LAPTOP-001', name: 'Gaming Laptop', price: 1299, inStock: true },
{ id: '2', sku: 'MOUSE-001', name: 'Wireless Mouse', price: 59, inStock: true }
],
categories: ['Electronics', 'Computers', 'Accessories'],
lastUpdated: new Date()
};
// β
Can update lastUpdated
catalog.lastUpdated = new Date();
// β Cannot modify the products array
// catalog.products.push(...); // Error!
// catalog.products[0] = ...; // Error!
// catalog.products.sort(); // Error!
// β
But can modify product properties that aren't readonly
catalog.products[0].name = 'Gaming Laptop Pro';
catalog.products[0].price = 1399;
// β Cannot modify readonly product properties
// catalog.products[0].id = '999'; // Error!
// catalog.products[0].sku = 'NEW-SKU'; // Error!
ποΈ Readonly Classes
Using readonly in class properties:
// π¦ Bank account with immutable properties
class BankAccount {
readonly accountNumber: string;
readonly accountHolder: string;
readonly openedDate: Date;
readonly accountType: 'checking' | 'savings';
private _balance: number;
readonly transactions: ReadonlyArray<Transaction>;
constructor(
accountNumber: string,
accountHolder: string,
accountType: 'checking' | 'savings',
initialBalance: number = 0
) {
this.accountNumber = accountNumber;
this.accountHolder = accountHolder;
this.accountType = accountType;
this.openedDate = new Date();
this._balance = initialBalance;
this.transactions = [];
}
// Getter for balance (read-only access)
get balance(): number {
return this._balance;
}
// Methods to modify state in controlled ways
deposit(amount: number): void {
if (amount <= 0) {
throw new Error('Deposit amount must be positive');
}
this._balance += amount;
// Create new array instead of mutating
(this as any).transactions = [
...this.transactions,
{
id: `txn_${Date.now()}`,
type: 'deposit',
amount,
timestamp: new Date(),
balance: this._balance
}
];
console.log(`π° Deposited $${amount}. New balance: $${this._balance}`);
}
withdraw(amount: number): void {
if (amount <= 0) {
throw new Error('Withdrawal amount must be positive');
}
if (amount > this._balance) {
throw new Error('Insufficient funds');
}
this._balance -= amount;
// Create new array instead of mutating
(this as any).transactions = [
...this.transactions,
{
id: `txn_${Date.now()}`,
type: 'withdrawal',
amount,
timestamp: new Date(),
balance: this._balance
}
];
console.log(`πΈ Withdrew $${amount}. New balance: $${this._balance}`);
}
getStatement(): string {
const lines = [
`Bank Account Statement`,
`=====================`,
`Account: ${this.accountNumber}`,
`Holder: ${this.accountHolder}`,
`Type: ${this.accountType}`,
`Opened: ${this.openedDate.toLocaleDateString()}`,
`Current Balance: $${this._balance.toFixed(2)}`,
``,
`Transaction History:`
];
this.transactions.forEach(txn => {
const sign = txn.type === 'deposit' ? '+' : '-';
lines.push(
`${txn.timestamp.toLocaleString()} | ${sign}$${txn.amount.toFixed(2)} | Balance: $${txn.balance.toFixed(2)}`
);
});
return lines.join('\n');
}
}
interface Transaction {
readonly id: string;
readonly type: 'deposit' | 'withdrawal';
readonly amount: number;
readonly timestamp: Date;
readonly balance: number;
}
// π Immutable configuration class
class AppConfiguration {
readonly appName: string;
readonly version: string;
readonly environment: 'development' | 'staging' | 'production';
readonly api: Readonly<{
baseUrl: string;
timeout: number;
retryAttempts: number;
}>;
readonly features: ReadonlyMap<string, boolean>;
readonly settings: ReadonlySet<string>;
constructor(config: {
appName: string;
version: string;
environment: 'development' | 'staging' | 'production';
apiBaseUrl: string;
apiTimeout?: number;
features?: Record<string, boolean>;
settings?: string[];
}) {
this.appName = config.appName;
this.version = config.version;
this.environment = config.environment;
this.api = Object.freeze({
baseUrl: config.apiBaseUrl,
timeout: config.apiTimeout ?? 30000,
retryAttempts: 3
});
this.features = new Map(Object.entries(config.features ?? {}));
this.settings = new Set(config.settings ?? []);
}
hasFeature(feature: string): boolean {
return this.features.get(feature) ?? false;
}
hasSetting(setting: string): boolean {
return this.settings.has(setting);
}
// Factory method for creating derived configs
withEnvironment(environment: AppConfiguration['environment']): AppConfiguration {
return new AppConfiguration({
appName: this.appName,
version: this.version,
environment,
apiBaseUrl: this.api.baseUrl,
apiTimeout: this.api.timeout,
features: Object.fromEntries(this.features),
settings: Array.from(this.settings)
});
}
}
// Usage examples
const account = new BankAccount('ACC-001', 'John Doe', 'checking', 1000);
account.deposit(500);
account.withdraw(200);
console.log(account.getStatement());
const config = new AppConfiguration({
appName: 'MyApp',
version: '1.0.0',
environment: 'development',
apiBaseUrl: 'https://api.dev.example.com',
features: {
darkMode: true,
betaFeatures: false
},
settings: ['autoSave', 'notifications']
});
console.log(`App: ${config.appName} v${config.version}`);
console.log(`Dark mode: ${config.hasFeature('darkMode')}`);
console.log(`Auto-save: ${config.hasSetting('autoSave')}`);
π¨ Advanced Patterns
π§ Deep Readonly with Utility Types
Creating deeply immutable structures:
// ποΈ Deep readonly utility type
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
// π State management with deep immutability
interface AppState {
user: {
id: string;
profile: {
name: string;
email: string;
preferences: {
theme: 'light' | 'dark';
language: string;
notifications: {
email: boolean;
push: boolean;
sms: boolean;
};
};
};
permissions: string[];
};
ui: {
sidebar: {
collapsed: boolean;
width: number;
};
modals: {
[key: string]: {
open: boolean;
data?: any;
};
};
};
data: {
items: Array<{
id: string;
name: string;
metadata: Record<string, any>;
}>;
loading: boolean;
error: string | null;
};
}
type ImmutableAppState = DeepReadonly<AppState>;
// π Immutable state manager
class StateManager<T> {
private _state: DeepReadonly<T>;
private listeners: Set<(state: DeepReadonly<T>) => void> = new Set();
constructor(initialState: T) {
this._state = this.deepFreeze(initialState) as DeepReadonly<T>;
}
get state(): DeepReadonly<T> {
return this._state;
}
// Deep freeze helper
private deepFreeze<T>(obj: T): T {
Object.freeze(obj);
Object.getOwnPropertyNames(obj).forEach(prop => {
if (obj[prop as keyof T] !== null
&& (typeof obj[prop as keyof T] === 'object' || typeof obj[prop as keyof T] === 'function')
&& !Object.isFrozen(obj[prop as keyof T])) {
this.deepFreeze(obj[prop as keyof T]);
}
});
return obj;
}
// Update state by creating new immutable state
update(updater: (draft: T) => void): void {
// Create a deep copy
const draft = JSON.parse(JSON.stringify(this._state)) as T;
// Apply updates
updater(draft);
// Freeze and update
this._state = this.deepFreeze(draft) as DeepReadonly<T>;
// Notify listeners
this.listeners.forEach(listener => listener(this._state));
}
subscribe(listener: (state: DeepReadonly<T>) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
// π― Immutable update patterns
class ImmutableUpdater {
// Update nested property
static setIn<T>(obj: T, path: string[], value: any): T {
if (path.length === 0) return value;
const [head, ...tail] = path;
const currentValue = (obj as any)[head];
const newValue = tail.length === 0
? value
: this.setIn(currentValue ?? {}, tail, value);
if (Array.isArray(obj)) {
const newArray = [...obj];
newArray[Number(head)] = newValue;
return newArray as any;
}
return {
...obj,
[head]: newValue
};
}
// Update array item
static updateArrayItem<T>(array: readonly T[], index: number, updater: (item: T) => T): T[] {
return array.map((item, i) => i === index ? updater(item) : item);
}
// Remove array item
static removeArrayItem<T>(array: readonly T[], index: number): T[] {
return array.filter((_, i) => i !== index);
}
// Add array item
static addArrayItem<T>(array: readonly T[], item: T): T[] {
return [...array, item];
}
}
// Usage example
const initialState: AppState = {
user: {
id: 'user_001',
profile: {
name: 'John Doe',
email: '[email protected]',
preferences: {
theme: 'light',
language: 'en',
notifications: {
email: true,
push: false,
sms: false
}
}
},
permissions: ['read', 'write']
},
ui: {
sidebar: {
collapsed: false,
width: 250
},
modals: {}
},
data: {
items: [],
loading: false,
error: null
}
};
const stateManager = new StateManager(initialState);
// Subscribe to changes
stateManager.subscribe(state => {
console.log('State updated:', state.user.profile.preferences.theme);
});
// Update state immutably
stateManager.update(draft => {
draft.user.profile.preferences.theme = 'dark';
draft.ui.sidebar.collapsed = true;
draft.data.items.push({
id: 'item_001',
name: 'New Item',
metadata: { created: new Date() }
});
});
// β This would fail at compile time:
// stateManager.state.user.profile.name = 'Jane'; // Error!
π Builder Pattern with Readonly
Creating immutable objects with builders:
// ποΈ Immutable builder pattern
class ImmutableUserBuilder {
private readonly _user: Partial<User> = {};
constructor(user?: Partial<User>) {
if (user) {
this._user = { ...user };
}
}
withId(id: string): ImmutableUserBuilder {
return new ImmutableUserBuilder({ ...this._user, id });
}
withName(firstName: string, lastName: string): ImmutableUserBuilder {
return new ImmutableUserBuilder({
...this._user,
profile: {
...this._user.profile,
firstName,
lastName
}
});
}
withEmail(email: string): ImmutableUserBuilder {
return new ImmutableUserBuilder({
...this._user,
profile: {
...this._user.profile,
email
}
});
}
withRole(role: UserRole): ImmutableUserBuilder {
return new ImmutableUserBuilder({
...this._user,
permissions: {
...this._user.permissions,
role
}
});
}
withFeature(feature: string, enabled: boolean): ImmutableUserBuilder {
const features = { ...this._user.permissions?.features };
features[feature] = enabled;
return new ImmutableUserBuilder({
...this._user,
permissions: {
...this._user.permissions,
features
}
});
}
build(): Readonly<User> {
if (!this._user.id || !this._user.profile?.email) {
throw new Error('User must have id and email');
}
const user: User = {
id: this._user.id,
profile: {
firstName: this._user.profile?.firstName ?? '',
lastName: this._user.profile?.lastName ?? '',
email: this._user.profile.email,
avatar: this._user.profile?.avatar
},
permissions: {
role: this._user.permissions?.role ?? 'user',
features: this._user.permissions?.features ?? {}
},
metadata: {
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: null
}
};
return Object.freeze(user);
}
}
interface User {
readonly id: string;
readonly profile: {
readonly firstName: string;
readonly lastName: string;
readonly email: string;
readonly avatar?: string;
};
readonly permissions: {
readonly role: UserRole;
readonly features: Readonly<Record<string, boolean>>;
};
readonly metadata: {
readonly createdAt: Date;
readonly updatedAt: Date;
readonly lastLoginAt: Date | null;
};
}
type UserRole = 'user' | 'admin' | 'moderator';
// π¨ Immutable data structures
class ImmutableList<T> {
private readonly items: ReadonlyArray<T>;
constructor(items: T[] = []) {
this.items = Object.freeze([...items]);
}
get length(): number {
return this.items.length;
}
get(index: number): T | undefined {
return this.items[index];
}
add(item: T): ImmutableList<T> {
return new ImmutableList([...this.items, item]);
}
remove(index: number): ImmutableList<T> {
return new ImmutableList(
this.items.filter((_, i) => i !== index)
);
}
update(index: number, item: T): ImmutableList<T> {
return new ImmutableList(
this.items.map((existing, i) => i === index ? item : existing)
);
}
map<U>(fn: (item: T, index: number) => U): ImmutableList<U> {
return new ImmutableList(this.items.map(fn));
}
filter(fn: (item: T, index: number) => boolean): ImmutableList<T> {
return new ImmutableList(this.items.filter(fn));
}
concat(other: ImmutableList<T>): ImmutableList<T> {
return new ImmutableList([...this.items, ...other.items]);
}
toArray(): ReadonlyArray<T> {
return this.items;
}
}
class ImmutableMap<K, V> {
private readonly map: ReadonlyMap<K, V>;
constructor(entries?: Iterable<[K, V]>) {
this.map = new Map(entries);
Object.freeze(this.map);
}
get size(): number {
return this.map.size;
}
get(key: K): V | undefined {
return this.map.get(key);
}
set(key: K, value: V): ImmutableMap<K, V> {
const entries = Array.from(this.map.entries());
const newEntries = entries.filter(([k]) => k !== key);
newEntries.push([key, value]);
return new ImmutableMap(newEntries);
}
delete(key: K): ImmutableMap<K, V> {
const entries = Array.from(this.map.entries()).filter(([k]) => k !== key);
return new ImmutableMap(entries);
}
has(key: K): boolean {
return this.map.has(key);
}
merge(other: ImmutableMap<K, V>): ImmutableMap<K, V> {
const entries = [
...Array.from(this.map.entries()),
...Array.from(other.map.entries())
];
return new ImmutableMap(entries);
}
toObject(): Readonly<Record<string, V>> {
const obj: Record<string, V> = {};
this.map.forEach((value, key) => {
obj[String(key)] = value;
});
return Object.freeze(obj);
}
}
// Usage examples
const user = new ImmutableUserBuilder()
.withId('user_001')
.withName('Jane', 'Smith')
.withEmail('[email protected]')
.withRole('admin')
.withFeature('darkMode', true)
.withFeature('betaFeatures', true)
.build();
console.log('Built user:', user);
// β Cannot modify: user.profile.firstName = 'John'; // Error!
const list = new ImmutableList([1, 2, 3])
.add(4)
.remove(0)
.update(0, 10);
console.log('List:', list.toArray()); // [10, 3, 4]
const map = new ImmutableMap([['a', 1], ['b', 2]])
.set('c', 3)
.delete('a');
console.log('Map size:', map.size); // 2
console.log('Map has b:', map.has('b')); // true
π Readonly with Generics
Type-safe immutable containers:
// π― Generic readonly container
class ReadonlyContainer<T> {
private readonly _value: DeepReadonly<T>;
constructor(value: T) {
this._value = this.deepFreeze(structuredClone(value)) as DeepReadonly<T>;
}
get value(): DeepReadonly<T> {
return this._value;
}
private deepFreeze<U>(obj: U): U {
Object.freeze(obj);
if (obj !== null && typeof obj === 'object') {
Object.getOwnPropertyNames(obj).forEach(prop => {
const value = (obj as any)[prop];
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
this.deepFreeze(value);
}
});
}
return obj;
}
// Transform the value immutably
map<U>(transformer: (value: DeepReadonly<T>) => U): ReadonlyContainer<U> {
return new ReadonlyContainer(transformer(this._value));
}
// Extract a nested value
pluck<K extends keyof T>(key: K): DeepReadonly<T[K]> {
return this._value[key];
}
}
// πͺ Immutable store pattern
interface StoreOptions<T> {
initialState: T;
persist?: boolean;
storageKey?: string;
}
class ImmutableStore<T extends object> {
private state: ReadonlyContainer<T>;
private subscribers = new Set<(state: DeepReadonly<T>) => void>();
private history: ReadonlyContainer<T>[] = [];
private historyIndex = -1;
private readonly options: StoreOptions<T>;
constructor(options: StoreOptions<T>) {
this.options = options;
// Try to load from storage
if (options.persist && options.storageKey) {
const stored = localStorage.getItem(options.storageKey);
if (stored) {
try {
const parsed = JSON.parse(stored);
this.state = new ReadonlyContainer(parsed);
} catch {
this.state = new ReadonlyContainer(options.initialState);
}
} else {
this.state = new ReadonlyContainer(options.initialState);
}
} else {
this.state = new ReadonlyContainer(options.initialState);
}
this.saveToHistory();
}
getState(): DeepReadonly<T> {
return this.state.value;
}
dispatch(action: (state: T) => T): void {
const currentState = JSON.parse(JSON.stringify(this.state.value)) as T;
const newState = action(currentState);
this.state = new ReadonlyContainer(newState);
this.saveToHistory();
this.notifySubscribers();
this.persist();
}
subscribe(callback: (state: DeepReadonly<T>) => void): () => void {
this.subscribers.add(callback);
callback(this.state.value); // Call immediately with current state
return () => {
this.subscribers.delete(callback);
};
}
private notifySubscribers(): void {
this.subscribers.forEach(callback => callback(this.state.value));
}
private saveToHistory(): void {
// Remove any forward history if we're not at the end
if (this.historyIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.historyIndex + 1);
}
this.history.push(this.state);
this.historyIndex = this.history.length - 1;
// Limit history size
if (this.history.length > 50) {
this.history = this.history.slice(-50);
this.historyIndex = this.history.length - 1;
}
}
undo(): boolean {
if (this.historyIndex > 0) {
this.historyIndex--;
this.state = this.history[this.historyIndex];
this.notifySubscribers();
this.persist();
return true;
}
return false;
}
redo(): boolean {
if (this.historyIndex < this.history.length - 1) {
this.historyIndex++;
this.state = this.history[this.historyIndex];
this.notifySubscribers();
this.persist();
return true;
}
return false;
}
private persist(): void {
if (this.options.persist && this.options.storageKey) {
localStorage.setItem(
this.options.storageKey,
JSON.stringify(this.state.value)
);
}
}
reset(): void {
this.state = new ReadonlyContainer(this.options.initialState);
this.history = [this.state];
this.historyIndex = 0;
this.notifySubscribers();
this.persist();
}
}
// Example: Todo app with immutable state
interface TodoState {
todos: Array<{
id: string;
text: string;
completed: boolean;
createdAt: Date;
}>;
filter: 'all' | 'active' | 'completed';
searchQuery: string;
}
const todoStore = new ImmutableStore<TodoState>({
initialState: {
todos: [],
filter: 'all',
searchQuery: ''
},
persist: true,
storageKey: 'todo-app-state'
});
// Action creators
const actions = {
addTodo: (text: string) => (state: TodoState): TodoState => ({
...state,
todos: [
...state.todos,
{
id: `todo_${Date.now()}`,
text,
completed: false,
createdAt: new Date()
}
]
}),
toggleTodo: (id: string) => (state: TodoState): TodoState => ({
...state,
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
}),
deleteTodo: (id: string) => (state: TodoState): TodoState => ({
...state,
todos: state.todos.filter(todo => todo.id !== id)
}),
setFilter: (filter: TodoState['filter']) => (state: TodoState): TodoState => ({
...state,
filter
}),
setSearchQuery: (searchQuery: string) => (state: TodoState): TodoState => ({
...state,
searchQuery
})
};
// Subscribe to changes
todoStore.subscribe(state => {
console.log('Todos:', state.todos.length);
console.log('Filter:', state.filter);
});
// Use the store
todoStore.dispatch(actions.addTodo('Learn TypeScript'));
todoStore.dispatch(actions.addTodo('Master readonly properties'));
todoStore.dispatch(actions.toggleTodo(todoStore.getState().todos[0].id));
// Undo/redo support
console.log('Can undo:', todoStore.undo()); // true
console.log('Can redo:', todoStore.redo()); // true
β οΈ Common Pitfalls and Solutions
π± Pitfall 1: Shallow Readonly
// β Problem - readonly is shallow
interface ShallowProblem {
readonly user: {
name: string;
settings: {
theme: string;
};
};
}
const problem: ShallowProblem = {
user: {
name: 'John',
settings: {
theme: 'light'
}
}
};
// β This is allowed! Readonly is shallow
problem.user.name = 'Jane';
problem.user.settings.theme = 'dark';
// β
Solution 1: Deep readonly utility
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
interface DeepSolution {
user: DeepReadonly<{
name: string;
settings: {
theme: string;
};
}>;
}
// β
Solution 2: Recursive readonly interfaces
interface RecursiveSolution {
readonly user: {
readonly name: string;
readonly settings: {
readonly theme: string;
};
};
}
// β
Solution 3: Use Readonly utility recursively
interface NestedSolution {
readonly user: Readonly<{
name: string;
settings: Readonly<{
theme: string;
}>;
}>;
}
π€― Pitfall 2: Arrays and Readonly
// β Problem - readonly array property vs ReadonlyArray
interface ArrayProblem {
readonly items: string[]; // This only makes the property readonly!
}
const arr: ArrayProblem = {
items: ['a', 'b', 'c']
};
// β Can still mutate the array!
arr.items.push('d');
arr.items[0] = 'modified';
// β
Solution - use ReadonlyArray or readonly modifier
interface ArraySolution {
readonly items: ReadonlyArray<string>;
// or
readonly items2: readonly string[];
}
const arrSolution: ArraySolution = {
items: ['a', 'b', 'c'],
items2: ['x', 'y', 'z']
};
// β
Now these cause errors:
// arrSolution.items.push('d'); // Error!
// arrSolution.items[0] = 'modified'; // Error!
// Working with readonly arrays
function processItems(items: readonly string[]): string[] {
// β Cannot mutate
// items.sort(); // Error!
// β
Create new array
return [...items].sort();
}
π Pitfall 3: Type Assertions Breaking Readonly
// β Dangerous - type assertions can bypass readonly
interface SafeConfig {
readonly apiKey: string;
readonly secretKey: string;
}
const config: SafeConfig = {
apiKey: 'public-key',
secretKey: 'secret-key'
};
// β Type assertion bypasses readonly!
(config as any).apiKey = 'hacked!';
// β
Solution - protect with closure
function createSafeConfig(apiKey: string, secretKey: string): SafeConfig {
const config = Object.freeze({
apiKey,
secretKey
});
return {
get apiKey() { return config.apiKey; },
get secretKey() { return config.secretKey; }
};
}
const safeConfig = createSafeConfig('public-key', 'secret-key');
// Even type assertions can't modify this!
π οΈ Best Practices
π― Readonly Design Guidelines
- Default to Immutable π: Make properties readonly by default
- Deep Immutability ποΈ: Use deep readonly for nested structures
- Immutable Updates π: Return new objects instead of mutating
- Type Safety π‘οΈ: Let TypeScript enforce immutability
// π Well-designed immutable API
interface ApiResponse<T> {
readonly data: DeepReadonly<T>;
readonly status: number;
readonly headers: ReadonlyMap<string, string>;
readonly timestamp: Date;
}
class ApiClient {
private readonly baseUrl: string;
private readonly defaultHeaders: ReadonlyMap<string, string>;
constructor(config: {
readonly baseUrl: string;
readonly headers?: Record<string, string>;
}) {
this.baseUrl = config.baseUrl;
this.defaultHeaders = new Map(Object.entries(config.headers ?? {}));
}
async get<T>(path: string): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: Object.fromEntries(this.defaultHeaders)
});
const data = await response.json();
return Object.freeze({
data: this.deepFreeze(data),
status: response.status,
headers: new Map(response.headers.entries()),
timestamp: new Date()
});
}
private deepFreeze<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
Object.freeze(obj);
Object.getOwnPropertyNames(obj).forEach(prop => {
if (obj[prop as keyof T] !== null && typeof obj[prop as keyof T] === 'object') {
this.deepFreeze(obj[prop as keyof T]);
}
});
return obj;
}
}
// ποΈ Immutable entity pattern
abstract class ImmutableEntity<T> {
protected readonly _data: DeepReadonly<T>;
protected readonly _id: string;
protected readonly _version: number;
constructor(data: T, id?: string, version: number = 1) {
this._data = this.freezeDeep(data) as DeepReadonly<T>;
this._id = id ?? this.generateId();
this._version = version;
}
get id(): string {
return this._id;
}
get version(): number {
return this._version;
}
protected abstract generateId(): string;
protected freezeDeep<U>(obj: U): U {
return JSON.parse(JSON.stringify(obj));
}
protected update(updates: Partial<T>): this {
const Constructor = this.constructor as new (data: T, id: string, version: number) => this;
const newData = { ...this._data, ...updates } as T;
return new Constructor(newData, this._id, this._version + 1);
}
}
// Example entity
class Product extends ImmutableEntity<{
name: string;
price: number;
stock: number;
}> {
get name(): string {
return this._data.name;
}
get price(): number {
return this._data.price;
}
get stock(): number {
return this._data.stock;
}
protected generateId(): string {
return `prod_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
withPrice(price: number): Product {
return this.update({ price });
}
withStock(stock: number): Product {
return this.update({ stock });
}
}
const product = new Product({
name: 'Laptop',
price: 999,
stock: 10
});
const discountedProduct = product.withPrice(799);
console.log(product.price); // 999 (original unchanged)
console.log(discountedProduct.price); // 799
console.log(discountedProduct.version); // 2
π§ͺ Hands-On Exercise
π― Challenge: Build an Immutable Event Store
Create an event sourcing system with complete immutability:
π Requirements:
- β Immutable event records
- π¨ Event replay functionality
- π― State snapshots
- π Time-travel debugging
- π§ Event aggregation
π Bonus Points:
- Add event compression
- Implement event streaming
- Create projections
π‘ Solution
π Click to see solution
// π― Immutable Event Store Implementation
// Base event interface
interface Event {
readonly id: string;
readonly type: string;
readonly timestamp: Date;
readonly aggregateId: string;
readonly version: number;
readonly payload: DeepReadonly<any>;
readonly metadata: DeepReadonly<{
userId?: string;
correlationId?: string;
causationId?: string;
}>;
}
// Deep readonly utility
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
// Event store interface
interface IEventStore {
append(event: Omit<Event, 'id' | 'timestamp'>): void;
getEvents(aggregateId: string, fromVersion?: number): readonly Event[];
getAllEvents(): readonly Event[];
getSnapshot(aggregateId: string): DeepReadonly<any> | null;
saveSnapshot(aggregateId: string, state: any, version: number): void;
}
// Aggregate root interface
interface IAggregateRoot<TState> {
readonly id: string;
readonly version: number;
readonly state: DeepReadonly<TState>;
loadFromHistory(events: readonly Event[]): void;
getUncommittedEvents(): readonly Event[];
markEventsAsCommitted(): void;
}
// Event store implementation
class InMemoryEventStore implements IEventStore {
private readonly events: Map<string, Event[]> = new Map();
private readonly snapshots: Map<string, { state: any; version: number }> = new Map();
private eventCounter = 0;
append(event: Omit<Event, 'id' | 'timestamp'>): void {
const fullEvent: Event = Object.freeze({
...event,
id: `evt_${++this.eventCounter}`,
timestamp: new Date()
});
const aggregateEvents = this.events.get(event.aggregateId) || [];
this.events.set(event.aggregateId, [...aggregateEvents, fullEvent]);
console.log(`π Event appended: ${fullEvent.type} for ${fullEvent.aggregateId}`);
}
getEvents(aggregateId: string, fromVersion: number = 0): readonly Event[] {
const events = this.events.get(aggregateId) || [];
return events.filter(e => e.version > fromVersion);
}
getAllEvents(): readonly Event[] {
const allEvents: Event[] = [];
this.events.forEach(events => allEvents.push(...events));
return allEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
}
getSnapshot(aggregateId: string): DeepReadonly<any> | null {
const snapshot = this.snapshots.get(aggregateId);
return snapshot ? this.deepFreeze(snapshot.state) : null;
}
saveSnapshot(aggregateId: string, state: any, version: number): void {
this.snapshots.set(aggregateId, {
state: this.deepFreeze(state),
version
});
console.log(`πΈ Snapshot saved for ${aggregateId} at version ${version}`);
}
private deepFreeze<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj;
const frozen = Object.freeze(obj);
Object.getOwnPropertyNames(frozen).forEach(prop => {
if (frozen[prop as keyof T] !== null && typeof frozen[prop as keyof T] === 'object') {
this.deepFreeze(frozen[prop as keyof T]);
}
});
return frozen;
}
}
// Base aggregate root
abstract class AggregateRoot<TState> implements IAggregateRoot<TState> {
protected _state: TState;
protected _version: number = 0;
protected uncommittedEvents: Event[] = [];
constructor(public readonly id: string) {
this._state = this.getInitialState();
}
get version(): number {
return this._version;
}
get state(): DeepReadonly<TState> {
return this.deepFreeze(this._state) as DeepReadonly<TState>;
}
protected abstract getInitialState(): TState;
protected abstract apply(event: Event): void;
loadFromHistory(events: readonly Event[]): void {
events.forEach(event => {
this.apply(event);
this._version = event.version;
});
}
getUncommittedEvents(): readonly Event[] {
return [...this.uncommittedEvents];
}
markEventsAsCommitted(): void {
this.uncommittedEvents = [];
}
protected raiseEvent(type: string, payload: any): void {
const event: Event = {
id: '', // Will be set by event store
type,
timestamp: new Date(), // Will be overridden by event store
aggregateId: this.id,
version: ++this._version,
payload: this.deepFreeze(payload),
metadata: {}
};
this.uncommittedEvents.push(event);
this.apply(event);
}
private deepFreeze<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return obj;
const cloned = JSON.parse(JSON.stringify(obj));
return Object.freeze(cloned);
}
}
// Example: Shopping cart aggregate
interface CartState {
items: Array<{
productId: string;
productName: string;
quantity: number;
price: number;
}>;
customerId: string;
status: 'active' | 'checked_out' | 'abandoned';
totalAmount: number;
createdAt: Date;
lastModified: Date;
}
class ShoppingCart extends AggregateRoot<CartState> {
protected getInitialState(): CartState {
return {
items: [],
customerId: '',
status: 'active',
totalAmount: 0,
createdAt: new Date(),
lastModified: new Date()
};
}
// Commands
create(customerId: string): void {
this.raiseEvent('CartCreated', { customerId });
}
addItem(productId: string, productName: string, quantity: number, price: number): void {
if (this._state.status !== 'active') {
throw new Error('Cannot add items to non-active cart');
}
this.raiseEvent('ItemAdded', {
productId,
productName,
quantity,
price
});
}
removeItem(productId: string): void {
if (this._state.status !== 'active') {
throw new Error('Cannot remove items from non-active cart');
}
const item = this._state.items.find(i => i.productId === productId);
if (!item) {
throw new Error('Item not found in cart');
}
this.raiseEvent('ItemRemoved', { productId });
}
updateQuantity(productId: string, quantity: number): void {
if (this._state.status !== 'active') {
throw new Error('Cannot update items in non-active cart');
}
if (quantity <= 0) {
throw new Error('Quantity must be positive');
}
const item = this._state.items.find(i => i.productId === productId);
if (!item) {
throw new Error('Item not found in cart');
}
this.raiseEvent('QuantityUpdated', { productId, quantity });
}
checkout(): void {
if (this._state.status !== 'active') {
throw new Error('Cart is not active');
}
if (this._state.items.length === 0) {
throw new Error('Cannot checkout empty cart');
}
this.raiseEvent('CartCheckedOut', {
totalAmount: this._state.totalAmount
});
}
// Event handlers
protected apply(event: Event): void {
switch (event.type) {
case 'CartCreated':
this._state = {
...this._state,
customerId: event.payload.customerId,
createdAt: event.timestamp,
lastModified: event.timestamp
};
break;
case 'ItemAdded':
const existingItem = this._state.items.find(
i => i.productId === event.payload.productId
);
if (existingItem) {
this._state = {
...this._state,
items: this._state.items.map(item =>
item.productId === event.payload.productId
? { ...item, quantity: item.quantity + event.payload.quantity }
: item
),
lastModified: event.timestamp
};
} else {
this._state = {
...this._state,
items: [...this._state.items, event.payload],
lastModified: event.timestamp
};
}
this.recalculateTotal();
break;
case 'ItemRemoved':
this._state = {
...this._state,
items: this._state.items.filter(
i => i.productId !== event.payload.productId
),
lastModified: event.timestamp
};
this.recalculateTotal();
break;
case 'QuantityUpdated':
this._state = {
...this._state,
items: this._state.items.map(item =>
item.productId === event.payload.productId
? { ...item, quantity: event.payload.quantity }
: item
),
lastModified: event.timestamp
};
this.recalculateTotal();
break;
case 'CartCheckedOut':
this._state = {
...this._state,
status: 'checked_out',
lastModified: event.timestamp
};
break;
}
}
private recalculateTotal(): void {
const total = this._state.items.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
this._state = {
...this._state,
totalAmount: total
};
}
}
// Event projection
class CartProjection {
private readonly carts: Map<string, DeepReadonly<CartState>> = new Map();
project(events: readonly Event[]): void {
// Group events by aggregate
const eventsByAggregate = new Map<string, Event[]>();
events.forEach(event => {
if (!event.type.startsWith('Cart')) return;
const aggregateEvents = eventsByAggregate.get(event.aggregateId) || [];
eventsByAggregate.set(event.aggregateId, [...aggregateEvents, event]);
});
// Rebuild each cart
eventsByAggregate.forEach((events, aggregateId) => {
const cart = new ShoppingCart(aggregateId);
cart.loadFromHistory(events);
this.carts.set(aggregateId, cart.state);
});
}
getActiveCartsCount(): number {
return Array.from(this.carts.values()).filter(cart => cart.status === 'active').length;
}
getTotalRevenue(): number {
return Array.from(this.carts.values())
.filter(cart => cart.status === 'checked_out')
.reduce((sum, cart) => sum + cart.totalAmount, 0);
}
getAverageCartValue(): number {
const carts = Array.from(this.carts.values());
if (carts.length === 0) return 0;
const total = carts.reduce((sum, cart) => sum + cart.totalAmount, 0);
return total / carts.length;
}
getTopProducts(): Array<{ productId: string; productName: string; count: number }> {
const productCounts = new Map<string, { name: string; count: number }>();
this.carts.forEach(cart => {
cart.items.forEach(item => {
const existing = productCounts.get(item.productId) || { name: item.productName, count: 0 };
productCounts.set(item.productId, {
name: item.productName,
count: existing.count + item.quantity
});
});
});
return Array.from(productCounts.entries())
.map(([productId, data]) => ({
productId,
productName: data.name,
count: data.count
}))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
}
}
// Time travel debugging
class EventDebugger {
constructor(private eventStore: IEventStore) {}
replayUntil(timestamp: Date): Map<string, any> {
const events = this.eventStore.getAllEvents()
.filter(e => e.timestamp <= timestamp);
const states = new Map<string, any>();
// Group by aggregate and replay
const eventsByAggregate = new Map<string, Event[]>();
events.forEach(event => {
const aggregateEvents = eventsByAggregate.get(event.aggregateId) || [];
eventsByAggregate.set(event.aggregateId, [...aggregateEvents, event]);
});
eventsByAggregate.forEach((events, aggregateId) => {
if (events[0].type.startsWith('Cart')) {
const cart = new ShoppingCart(aggregateId);
cart.loadFromHistory(events);
states.set(aggregateId, cart.state);
}
});
return states;
}
getEventTimeline(): Array<{ timestamp: Date; type: string; aggregateId: string }> {
return this.eventStore.getAllEvents().map(event => ({
timestamp: event.timestamp,
type: event.type,
aggregateId: event.aggregateId
}));
}
}
// Demo usage
console.log('=== Event Store Demo ===\n');
const eventStore = new InMemoryEventStore();
// Create shopping carts
const cart1 = new ShoppingCart('cart_001');
cart1.create('customer_001');
cart1.addItem('prod_001', 'Laptop', 1, 999.99);
cart1.addItem('prod_002', 'Mouse', 2, 29.99);
cart1.updateQuantity('prod_002', 3);
// Save events
cart1.getUncommittedEvents().forEach(event => eventStore.append(event));
cart1.markEventsAsCommitted();
// Create another cart
const cart2 = new ShoppingCart('cart_002');
cart2.create('customer_002');
cart2.addItem('prod_001', 'Laptop', 2, 999.99);
cart2.addItem('prod_003', 'Keyboard', 1, 79.99);
cart2.checkout();
cart2.getUncommittedEvents().forEach(event => eventStore.append(event));
cart2.markEventsAsCommitted();
// Create projection
const projection = new CartProjection();
projection.project(eventStore.getAllEvents());
console.log('\nπ Analytics:');
console.log(`Active carts: ${projection.getActiveCartsCount()}`);
console.log(`Total revenue: $${projection.getTotalRevenue().toFixed(2)}`);
console.log(`Average cart value: $${projection.getAverageCartValue().toFixed(2)}`);
console.log('\nTop products:');
projection.getTopProducts().forEach(product => {
console.log(`- ${product.productName}: ${product.count} units`);
});
// Time travel debugging
const debugger = new EventDebugger(eventStore);
console.log('\nπ Event Timeline:');
debugger.getEventTimeline().forEach(event => {
console.log(`${event.timestamp.toISOString()} - ${event.type} (${event.aggregateId})`);
});
// Snapshot example
eventStore.saveSnapshot('cart_001', cart1.state, cart1.version);
const snapshot = eventStore.getSnapshot('cart_001');
console.log('\nπΈ Snapshot:', snapshot);
// Try to modify snapshot (will fail at runtime due to freeze)
try {
(snapshot as any).items.push({ productId: 'hack', quantity: 1 });
} catch (e) {
console.log('\nβ
Snapshot is properly immutable!');
}
π Key Takeaways
You now understand how to leverage readonly properties for immutable design! Hereβs what youβve learned:
- β Readonly modifier prevents property reassignment π
- β Deep immutability requires recursive readonly application ποΈ
- β Immutable patterns create predictable, bug-free code π‘οΈ
- β Builder and factory patterns work great with readonly ποΈ
- β Event sourcing benefits from immutable events π
Remember: Immutability isnβt just a constraint - itβs a powerful design tool that makes your code more predictable, testable, and maintainable! π
π€ Next Steps
Congratulations! π Youβve mastered readonly properties and immutable design patterns!
Hereβs what to do next:
- π» Practice with the event store exercise above
- ποΈ Refactor existing code to use immutable patterns
- π Continue learning advanced TypeScript concepts
- π Apply immutability principles to your real projects!
Remember: The best bugs are the ones that canβt happen. Make impossibility your ally with readonly! π
Happy coding! ππβ¨