+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Part 45 of 354

πŸ”’ Readonly Properties: Immutable Object Design

Master readonly properties in TypeScript to create immutable, predictable, and thread-safe object designs πŸš€

πŸš€Intermediate
25 min read

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:

  1. Predictability 🎯: Data doesn’t change unexpectedly
  2. Thread Safety πŸ”„: No race conditions with immutable data
  3. Debugging πŸ›: Easier to track data flow
  4. 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

  1. Default to Immutable πŸ”’: Make properties readonly by default
  2. Deep Immutability πŸ”οΈ: Use deep readonly for nested structures
  3. Immutable Updates πŸ”„: Return new objects instead of mutating
  4. 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:

  1. πŸ’» Practice with the event store exercise above
  2. πŸ—οΈ Refactor existing code to use immutable patterns
  3. πŸ“š Continue learning advanced TypeScript concepts
  4. 🌟 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! πŸŽ‰πŸš€βœ¨