All files / src/helpers collectionUtils.ts

93.65% Statements 59/63
93.33% Branches 28/30
100% Functions 2/2
93.65% Lines 59/63

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 1451x   1x         1x   1x                                   1x 1x 15x 15x   15x     15x 13x 15x       15x 15x 15x     15x             1x                                                                               1x 1x 140x 140x 140x   140x 140x   138x 140x 3x 3x   135x 140x 3x 3x   132x 140x 95x 89x 6x 95x   37x 140x 1x 1x   36x 36x 36x   140x 9x 9x 140x 27x 27x 27x   140x 121x 121x 121x 121x   36x 36x  
import { isPlainContainer } from '@/utils/typeGuards/common';
 
/**
 * @category Collection Utilities
 * @categoryDescription Collection utilities for working with arrays and objects.
 * @showCategories
 * @source
 */
 
/**
 * Omit properties from an object.
 *
 * @category Collection Utilities
 * @param data - The object to omit properties from.
 * @param path - The property or properties to omit.
 * @returns The object with the specified properties omitted.
 * @example
 * ```typescript
 * const data = {
 *   name: "John",
 *   age: 30,
 *   city: "New York",
 * };
 * omit(data, "age"); // { name: "John", city: "New York" }
 * omit(data, ["age", "city"]); // { name: "John" }
 * ```
 * @source
 */
export function omit<T extends object, K extends keyof T>(
  data: T,
  path: MaybeArray<K>,
): Omit<T, K> {
  if (!path) {
    return data;
  }
  if (typeof path === 'string') {
    path = [path];
  } else if (!Array.isArray(path)) {
    throw new Error('path must be a string or an array of strings');
  }
 
  return Object.fromEntries(
    // `key` is a runtime own-enumerable key of `T`, safe to narrow to `K` for membership check
    Object.entries(data).filter(([key]) => !path.includes(key as K)),
    // `Object.fromEntries` is typed as `{ [k: string]: unknown }`; result is structurally `Omit<T, K>`
  ) as Omit<T, K>;
}
 
type DiffChange =
  | { type: 'created'; path: (string | number)[]; value: unknown }
  | { type: 'deleted'; path: (string | number)[]; value: unknown }
  | { type: 'modified'; path: (string | number)[]; oldValue: unknown; newValue: unknown };
 
/**
 * Deep-diffs two arbitrary values and returns the list of changes as a flat
 * array of `DiffChange` entries.
 *
 * Walk semantics:
 *   - Both `null` / `undefined` → empty result (no change).
 *   - Old missing, new present → `{ type: "created", path, value }`.
 *   - New missing, old present → `{ type: "deleted", path, value }`.
 *   - Primitive (or non-plain-container, e.g. `Date`, `Map`) on either side →
 *     a single `"modified"` entry when `Object.is` says they differ.
 *   - Array vs. object on opposite sides → wholesale `"modified"` entry.
 *   - Plain object / array on both sides → recurses per-key; the union of
 *     both sides' keys is walked so missing keys surface as created/deleted.
 *
 * Claude may or may not have stolen this right from Lodash.
 * @category Collection Utilities
 * @param oldObj - Previous value. Pass `null`/`undefined` for "nothing there".
 * @param newObj - New value. Pass `null`/`undefined` for "gone".
 * @param path - Accumulator used by the recursion; callers should omit it.
 * @returns List of `DiffChange` entries — empty when the values are equal.
 * @example
 * ```typescript
 * diff({ a: 1, b: 2 }, { a: 1, b: 3 });
 * // [{ type: "modified", path: ["b"], oldValue: 2, newValue: 3 }]
 *
 * diff({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, d: 4 });
 * // [
 * //   { type: "deleted", path: ["c"], value: 3 },
 * //   { type: "created", path: ["d"], value: 4 },
 * // ]
 *
 * diff({ user: { name: "Ann" } }, { user: { name: "Bea" } });
 * // [{ type: "modified", path: ["user", "name"], oldValue: "Ann", newValue: "Bea" }]
 *
 * diff([1, 2, 3], [1, 2, 3, 4]);
 * // [{ type: "created", path: [3], value: 4 }]
 *
 * diff({ a: 1 }, { a: 1 }); // []
 * ```
 * @source
 */
export function diff(
  oldObj: unknown,
  newObj: unknown,
  path: (string | number)[] = [],
): DiffChange[] {
  // Both missing — no change
  if (oldObj == null && newObj == null) return [];
 
  // Creation: old is missing, new exists
  if (oldObj == null) {
    return [{ type: 'created', path, value: newObj }];
  }
 
  // Deletion: new is missing, old exists
  if (newObj == null) {
    return [{ type: 'deleted', path, value: oldObj }];
  }
 
  // Primitive comparison (or type mismatch between object and primitive)
  if (!isPlainContainer(oldObj) || !isPlainContainer(newObj)) {
    return Object.is(oldObj, newObj)
      ? []
      : [{ type: 'modified', path, oldValue: oldObj, newValue: newObj }];
  }
 
  // Array/object type mismatch — treat as wholesale replacement
  if (Array.isArray(oldObj) !== Array.isArray(newObj)) {
    return [{ type: 'modified', path, oldValue: oldObj, newValue: newObj }];
  }
 
  // Recurse into container
  const changes: DiffChange[] = [];
  const keys = new Set<string | number>();
 
  if (Array.isArray(oldObj) && Array.isArray(newObj)) {
    const maxLen = Math.max(oldObj.length, newObj.length);
    for (let i = 0; i < maxLen; i++) keys.add(i);
  } else {
    for (const k of Object.keys(oldObj)) keys.add(k);
    for (const k of Object.keys(newObj)) keys.add(k);
  }
 
  for (const key of keys) {
    const oldVal = Reflect.get(oldObj, key);
    const newVal = Reflect.get(newObj, key);
    changes.push(...diff(oldVal, newVal, [...path, key]));
  }
 
  return changes;
}