fp-immutable

Practical immutability patterns in TypeScript - spread operators, nested updates, readonly types, and when mutation is actually fine

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "fp-immutable" with this command: npx skills add whatiskadudoing/fp-ts-skills/whatiskadudoing-fp-ts-skills-fp-immutable

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

TaskImmutable Pattern
Add to array end[...arr, item]
Add to array start[item, ...arr]
Remove from arrayarr.filter(x => x !== item)
Update array itemarr.map(x => x.id === id ? {...x, ...updates} : x)
Update object prop{...obj, prop: newValue}
Remove object propconst {prop, ...rest} = obj
Merge objects{...defaults, ...overrides}
Deep updateUse Immer or nested spread
Prevent mutationreadonly types or Object.freeze()

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

functional programming fundamentals

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

functional programming in react

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

pragmatic functional programming

No summary provided by upstream source.

Repository SourceNeeds Review