📚 4 min read
Immutable State in TypeScript ​
This section explores patterns and best practices for implementing immutable state in TypeScript applications.
Overview ​
Immutable state is a fundamental concept in modern application development that helps prevent bugs and makes state changes more predictable and traceable.
Core Concepts ​
1. Readonly Types ​
typescript
// Basic readonly type
type ReadonlyUser = Readonly<User>;
// Deep readonly type
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};
interface Config {
server: {
port: number;
host: string;
};
database: {
url: string;
};
}
type ImmutableConfig = DeepReadonly<Config>;
2. Immutable Collections ​
typescript
class ImmutableList<T> {
private readonly items: ReadonlyArray<T>;
constructor(items: T[] = []) {
this.items = Object.freeze([...items]);
}
add(item: T): ImmutableList<T> {
return new ImmutableList([...this.items, item]);
}
remove(predicate: (item: T) => boolean): ImmutableList<T> {
return new ImmutableList(this.items.filter((item) => !predicate(item)));
}
map<U>(fn: (item: T) => U): ImmutableList<U> {
return new ImmutableList(this.items.map(fn));
}
filter(predicate: (item: T) => boolean): ImmutableList<T> {
return new ImmutableList(this.items.filter(predicate));
}
toArray(): ReadonlyArray<T> {
return this.items;
}
}
Advanced Patterns ​
1. Immutable State Updates ​
typescript
class ImmutableStore<T extends object> {
private readonly state: DeepReadonly<T>;
constructor(initialState: T) {
this.state = Object.freeze(initialState) as DeepReadonly<T>;
}
getState(): DeepReadonly<T> {
return this.state;
}
update(updater: (state: T) => T): ImmutableStore<T> {
return new ImmutableStore(updater({ ...(this.state as T) }));
}
updatePath<K extends keyof T>(
path: (string | number)[],
value: any
): ImmutableStore<T> {
return this.update((state) => {
const newState = { ...state };
let current: any = newState;
for (let i = 0; i < path.length - 1; i++) {
current[path[i]] = { ...current[path[i]] };
current = current[path[i]];
}
current[path[path.length - 1]] = value;
return newState;
});
}
}
2. Immutable Records ​
typescript
class Record<T extends object> {
private readonly data: DeepReadonly<T>;
constructor(data: T) {
this.data = Object.freeze(data) as DeepReadonly<T>;
}
with(partial: Partial<T>): Record<T> {
return new Record({ ...(this.data as T), ...partial });
}
get<K extends keyof T>(key: K): T[K] {
return this.data[key];
}
toObject(): DeepReadonly<T> {
return this.data;
}
}
Real-World Example ​
typescript
// Domain types
interface Product {
id: string;
name: string;
price: number;
stock: number;
}
interface CartItem {
productId: string;
quantity: number;
}
interface ShoppingCart {
items: CartItem[];
total: number;
}
// Immutable shopping cart implementation
class ImmutableCart {
private readonly store: ImmutableStore<ShoppingCart>;
private readonly products: ImmutableList<Product>;
constructor(products: Product[]) {
this.store = new ImmutableStore<ShoppingCart>({
items: [],
total: 0,
});
this.products = new ImmutableList(products);
}
addItem(productId: string, quantity: number): ImmutableCart {
const product = this.products.toArray().find((p) => p.id === productId);
if (!product) {
throw new Error('Product not found');
}
const newCart = new ImmutableCart(this.products.toArray());
newCart.store = this.store.update((cart) => {
const existingItem = cart.items.find(
(item) => item.productId === productId
);
const newItems = existingItem
? cart.items.map((item) =>
item.productId === productId
? { ...item, quantity: item.quantity + quantity }
: item
)
: [...cart.items, { productId, quantity }];
return {
items: newItems,
total: newItems.reduce((sum, item) => {
const product = this.products
.toArray()
.find((p) => p.id === item.productId);
return sum + (product?.price ?? 0) * item.quantity;
}, 0),
};
});
return newCart;
}
removeItem(productId: string): ImmutableCart {
const newCart = new ImmutableCart(this.products.toArray());
newCart.store = this.store.update((cart) => ({
items: cart.items.filter((item) => item.productId !== productId),
total: cart.items
.filter((item) => item.productId !== productId)
.reduce((sum, item) => {
const product = this.products
.toArray()
.find((p) => p.id === item.productId);
return sum + (product?.price ?? 0) * item.quantity;
}, 0),
}));
return newCart;
}
getCart(): DeepReadonly<ShoppingCart> {
return this.store.getState();
}
}
// Usage
const products: Product[] = [
{ id: '1', name: 'Book', price: 10, stock: 5 },
{ id: '2', name: 'Pen', price: 2, stock: 10 },
];
let cart = new ImmutableCart(products);
// Add items
cart = cart.addItem('1', 2); // Add 2 books
cart = cart.addItem('2', 3); // Add 3 pens
console.log(cart.getCart());
// {
// items: [
// { productId: '1', quantity: 2 },
// { productId: '2', quantity: 3 }
// ],
// total: 26
// }
// Remove an item
cart = cart.removeItem('1');
console.log(cart.getCart());
// {
// items: [
// { productId: '2', quantity: 3 }
// ],
// total: 6
// }
Best Practices ​
Immutability Patterns:
- Use TypeScript's readonly modifiers
- Implement deep immutability
- Return new instances on updates
Performance Considerations:
- Use structural sharing
- Implement memoization
- Consider using Immutable.js for large datasets
Type Safety:
- Leverage TypeScript's type system
- Use const assertions
- Implement proper type guards