mcpField
F
fp-immutable

fp-immutable

github.com/whatiskadudoingDevelopment1454 installsUpdated 39w ago
Repository

About fp-immutable

A practical immutability skill for TypeScript developers. It covers updating arrays, objects, and nested state without unintended shared-state changes, along with patterns using Immer and readonly types.

Key features

  1. 01Update arrays and objects immutably
  2. 02Handle nested data updates
  3. 03Restrict changes with readonly types
  4. 04Simplify complex updates with Immer
  5. 05Identify cases suited to local mutation

Use cases

  1. 01Update React or Redux state
  2. 02Keep prior state for undo or comparison
  3. 03Change an array item by ID
  4. 04Merge defaults with overrides

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/whatiskadudoing/fp-ts-skills --skill fp-immutable

After installing, type/fp-immutablein Claude Code to confirm it loaded.

Details

From the project README

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: '[email protected]' };

// 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()

Comments

Markdown supported