Fp-ts-skills fp-immutable
Practical immutability patterns in TypeScript - spread operators, nested updates, readonly types, and when mutation is actually fine
install
source · Clone the upstream repo
git clone https://github.com/whatiskadudoing/fp-ts-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/whatiskadudoing/fp-ts-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/fp-immutable" ~/.claude/skills/whatiskadudoing-fp-ts-skills-fp-immutable && rm -rf "$T"
manifest:
skills/fp-immutable/SKILL.mdsource content
Practical Immutability in TypeScript
Why Immutability Helps
// Bug: shared state causes unexpected behavior const filters = { active: true, category: 'all' }; const savedFilters = filters; // Not a copy! filters.active = false; console.log(savedFilters.active); // false - oops! // Fix: immutable update creates a new object const filters2 = { active: true, category: 'all' }; const savedFilters2 = { ...filters2 }; // Actual copy filters2.active = false; console.log(savedFilters2.active); // true - safe!
Benefits in practice:
- Debugging: Previous state preserved, easy to compare
- Undo/redo: Just keep old versions
- React/Redux: Change detection with
=== - No side effects: Functions don't break other code
Spread Patterns
Arrays
const items = [1, 2, 3]; // Add to end const added = [...items, 4]; // [1, 2, 3, 4] // Add to start const prepended = [0, ...items]; // [0, 1, 2, 3] // Insert at index const inserted = [...items.slice(0, 1), 99, ...items.slice(1)]; // [1, 99, 2, 3] // Remove by index const removed = [...items.slice(0, 1), ...items.slice(2)]; // [1, 3] // Remove by value const filtered = items.filter(x => x !== 2); // [1, 3] // Update by index const updated = items.map((x, i) => i === 1 ? 99 : x); // [1, 99, 3] // Replace matching items const replaced = items.map(x => x === 2 ? 99 : x); // [1, 99, 3]
Objects
const user = { name: 'Alice', age: 30, role: 'admin' }; // Update property const older = { ...user, age: 31 }; // Add property const withEmail = { ...user, email: 'alice@example.com' }; // Remove property (destructure + spread) const { role, ...withoutRole } = user; // { name: 'Alice', age: 30 } // Merge objects (later wins) const defaults = { theme: 'light', lang: 'en' }; const prefs = { theme: 'dark' }; const merged = { ...defaults, ...prefs }; // { theme: 'dark', lang: 'en' } // Conditional update const maybeUpdate = { ...user, ...(shouldUpdate && { age: 31 }) };
Updating Nested Data
The annoying part - every level needs spreading.
interface State { user: { profile: { name: string; settings: { theme: string; notifications: boolean; }; }; }; } const state: State = { user: { profile: { name: 'Alice', settings: { theme: 'light', notifications: true, }, }, }, }; // Update deeply nested value const updated: State = { ...state, user: { ...state.user, profile: { ...state.user.profile, settings: { ...state.user.profile.settings, theme: 'dark', }, }, }, };
Helper Function for Nested Updates
// Simple lens-like helper const updateIn = <T>( obj: T, path: string[], updater: (val: any) => any ): T => { if (path.length === 0) return updater(obj) as T; const [key, ...rest] = path; return { ...obj, [key]: updateIn((obj as any)[key], rest, updater), } as T; }; // Usage const updated2 = updateIn(state, ['user', 'profile', 'settings', 'theme'], () => 'dark');
Common Tasks
Toggle Boolean in a List Item
interface Todo { id: number; text: string; done: boolean; } const todos: Todo[] = [ { id: 1, text: 'Learn FP', done: false }, { id: 2, text: 'Use immutability', done: false }, ]; // Toggle by ID const toggleTodo = (todos: Todo[], id: number): Todo[] => todos.map(todo => todo.id === id ? { ...todo, done: !todo.done } : todo ); const toggled = toggleTodo(todos, 1); // [{ id: 1, text: 'Learn FP', done: true }, ...]
Update Item in Array by ID
interface User { id: number; name: string; score: number; } const users: User[] = [ { id: 1, name: 'Alice', score: 100 }, { id: 2, name: 'Bob', score: 85 }, ]; // Update specific user const updateUser = ( users: User[], id: number, updates: Partial<User> ): User[] => users.map(user => user.id === id ? { ...user, ...updates } : user ); const updated3 = updateUser(users, 1, { score: 110 });
Merge with Defaults
interface Config { timeout: number; retries: number; baseUrl: string; } const defaults: Config = { timeout: 5000, retries: 3, baseUrl: 'https://api.example.com', }; const createConfig = (overrides: Partial<Config>): Config => ({ ...defaults, ...overrides, }); const config = createConfig({ timeout: 10000 }); // { timeout: 10000, retries: 3, baseUrl: 'https://api.example.com' }
Clone with Modifications
interface Order { id: string; items: string[]; status: 'pending' | 'shipped' | 'delivered'; metadata: Record<string, string>; } const cloneOrder = (order: Order, modifications: Partial<Order>): Order => ({ ...order, ...modifications, // Deep clone arrays and objects if needed items: modifications.items ?? [...order.items], metadata: { ...order.metadata, ...modifications.metadata }, });
The Truth About const
// const prevents REASSIGNMENT, not MUTATION const arr = [1, 2, 3]; arr.push(4); // Works! arr is now [1, 2, 3, 4] arr[0] = 99; // Works! arr is now [99, 2, 3, 4] // arr = [5, 6, 7]; // Error: Cannot assign to 'arr' const obj = { name: 'Alice' }; obj.name = 'Bob'; // Works! obj is now { name: 'Bob' } // obj = {}; // Error: Cannot assign to 'obj' // Use readonly types for compile-time immutability const readonlyArr: readonly number[] = [1, 2, 3]; // readonlyArr.push(4); // Error: Property 'push' does not exist // readonlyArr[0] = 99; // Error: Index signature only permits reading const readonlyObj: Readonly<{ name: string }> = { name: 'Alice' }; // readonlyObj.name = 'Bob'; // Error: Cannot assign to 'name' // Object.freeze() for runtime immutability (shallow!) const frozen = Object.freeze({ name: 'Alice', nested: { value: 1 } }); // frozen.name = 'Bob'; // Silently fails (or throws in strict mode) frozen.nested.value = 2; // Works! freeze is shallow
readonly Types in TypeScript
// Readonly array type ImmutableList<T> = readonly T[]; // Readonly object type ImmutableUser = Readonly<{ name: string; age: number; }>; // Deep readonly (recursive) type DeepReadonly<T> = { readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]; }; // Usage interface State { users: Array<{ name: string; active: boolean }>; settings: { theme: string }; } type ImmutableState = DeepReadonly<State>; // All nested properties are now readonly
When Mutation Is OK
Inside Functions with Local Variables
// Mutation inside function is fine - no external state affected const sumSquares = (nums: number[]): number => { let total = 0; // Local mutable variable for (const n of nums) { total += n * n; // Mutation is fine here } return total; }; // Building up a result locally const groupBy = <T, K extends string>( items: T[], keyFn: (item: T) => K ): Record<K, T[]> => { const result: Record<string, T[]> = {}; // Local mutation for (const item of items) { const key = keyFn(item); if (!result[key]) result[key] = []; result[key].push(item); // Mutating local object } return result; };
Performance-Critical Code
// When processing large arrays, mutation can be significantly faster const processLargeArray = (items: number[]): number[] => { // Creating new arrays in a hot loop = GC pressure // Mutation here is a valid optimization const result = new Array(items.length); for (let i = 0; i < items.length; i++) { result[i] = items[i] * 2; } return result; }; // But profile first! Often the difference doesn't matter // and immutable code is easier to reason about
When Immutability Adds No Value
// Single-use object being built up const buildConfig = () => { const config: Record<string, unknown> = {}; config.env = process.env.NODE_ENV; config.debug = process.env.DEBUG === 'true'; config.port = parseInt(process.env.PORT || '3000'); return Object.freeze(config); // Freeze before returning }; // Local array being populated const fetchAllPages = async <T>(fetchPage: (n: number) => Promise<T[]>): Promise<T[]> => { const allItems: T[] = []; let page = 1; while (true) { const items = await fetchPage(page); if (items.length === 0) break; allItems.push(...items); // Local mutation is fine page++; } return allItems; };
Immer for Complex Updates
When spread nesting gets painful, use Immer.
import { produce } from 'immer'; interface State { users: Array<{ id: number; name: string; posts: Array<{ id: number; title: string; likes: number; }>; }>; } const state: State = { users: [ { id: 1, name: 'Alice', posts: [ { id: 1, title: 'Hello', likes: 5 }, { id: 2, title: 'World', likes: 3 }, ], }, ], }; // Without Immer - nested spread nightmare const withoutImmer: State = { ...state, users: state.users.map(user => user.id === 1 ? { ...user, posts: user.posts.map(post => post.id === 1 ? { ...post, likes: post.likes + 1 } : post ), } : user ), }; // With Immer - write mutations, get immutability const withImmer = produce(state, draft => { const user = draft.users.find(u => u.id === 1); if (user) { const post = user.posts.find(p => p.id === 1); if (post) { post.likes += 1; // Looks like mutation, but it's safe! } } }); // Both produce the same immutable result
Immer with React useState
import { useImmer } from 'use-immer'; interface FormState { user: { name: string; email: string }; preferences: { theme: string; notifications: boolean }; } const MyComponent = () => { const [state, updateState] = useImmer<FormState>({ user: { name: '', email: '' }, preferences: { theme: 'light', notifications: true }, }); const updateName = (name: string) => { updateState(draft => { draft.user.name = name; }); }; const toggleNotifications = () => { updateState(draft => { draft.preferences.notifications = !draft.preferences.notifications; }); }; };
Quick Reference
| Task | Immutable Pattern |
|---|---|
| Add to array end | |
| Add to array start | |
| Remove from array | |
| Update array item | |
| Update object prop | |
| Remove object prop | |
| Merge objects | |
| Deep update | Use Immer or nested spread |
| Prevent mutation | types or |