feat(stdlib): add immutable array ops, deep set, and isEqual comparator

Adds arrays/{move,insert,swap,remove}, collections/set, and comparators/isEqual (NaN/Date/RegExp/Map/Set/cycle-safe), wired into the barrels.
This commit is contained in:
2026-06-08 15:50:59 +07:00
parent 4678a372b1
commit 74fbd0c005
22 changed files with 479 additions and 7 deletions
+4
View File
@@ -1,10 +1,14 @@
export * from './cluster';
export * from './first';
export * from './groupBy';
export * from './insert';
export * from './last';
export * from './move';
export * from './partition';
export * from './range';
export * from './remove';
export * from './sum';
export * from './swap';
export * from './toArray';
export * from './unique';
export * from './zip';
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { insert } from '.';
describe('insert', () => {
it('insert a single item', () => {
expect(insert(['a', 'c'], 1, 'b')).toEqual(['a', 'b', 'c']);
});
it('insert multiple items', () => {
expect(insert(['a', 'd'], 1, 'b', 'c')).toEqual(['a', 'b', 'c', 'd']);
});
it('prepend at index 0', () => {
expect(insert(['b', 'c'], 0, 'a')).toEqual(['a', 'b', 'c']);
});
it('append when the index is too large', () => {
expect(insert(['a'], 99, 'b', 'c')).toEqual(['a', 'b', 'c']);
});
it('clamp a negative index to 0', () => {
expect(insert(['b'], -5, 'a')).toEqual(['a', 'b']);
});
it('never mutate the source', () => {
const source = ['a', 'b'];
insert(source, 1, 'x');
expect(source).toEqual(['a', 'b']);
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* @name insert
* @category Arrays
* @description Return a new array with `items` inserted at `index`. The index is
* clamped into `[0, length]`, so a too-large index appends. Never mutates.
*
* @param {readonly T[]} array - The source array
* @param {number} index - Position to insert at
* @param {...T} items - Items to insert
* @returns {T[]} A new array with the items inserted
*
* @example
* insert(['a', 'c'], 1, 'b'); // ['a', 'b', 'c']
* insert(['a'], 99, 'b', 'c'); // ['a', 'b', 'c']
*
* @since 0.0.10
*/
export function insert<T>(array: readonly T[], index: number, ...items: T[]): T[] {
const result = array.slice();
const target = Math.max(0, Math.min(index, result.length));
result.splice(target, 0, ...items);
return result;
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { move } from '.';
describe('move', () => {
it('move an item forward', () => {
expect(move(['a', 'b', 'c'], 0, 2)).toEqual(['b', 'c', 'a']);
});
it('move an item backward', () => {
expect(move(['a', 'b', 'c'], 2, 0)).toEqual(['c', 'a', 'b']);
});
it('clamp the target index', () => {
expect(move(['a', 'b', 'c'], 0, 99)).toEqual(['b', 'c', 'a']);
});
it('return a copy unchanged for an out-of-range source', () => {
const source = ['a', 'b'];
const result = move(source, 5, 0);
expect(result).toEqual(['a', 'b']);
expect(result).not.toBe(source);
});
it('never mutate the source', () => {
const source = ['a', 'b', 'c'];
move(source, 0, 2);
expect(source).toEqual(['a', 'b', 'c']);
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* @name move
* @category Arrays
* @description Return a new array with the item at `from` moved to `to`. Out-of-range
* `from` returns a shallow copy unchanged; `to` is clamped into range. Never mutates.
*
* @param {readonly T[]} array - The source array
* @param {number} from - Index to move from
* @param {number} to - Index to move to
* @returns {T[]} A new array with the item moved
*
* @example
* move(['a', 'b', 'c'], 0, 2); // ['b', 'c', 'a']
*
* @since 0.0.10
*/
export function move<T>(array: readonly T[], from: number, to: number): T[] {
const result = array.slice();
if (from < 0 || from >= result.length)
return result;
const item = result.splice(from, 1)[0] as T;
const target = Math.max(0, Math.min(to, result.length));
result.splice(target, 0, item);
return result;
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { remove } from '.';
describe('remove', () => {
it('remove an item by index', () => {
expect(remove(['a', 'b', 'c'], 1)).toEqual(['a', 'c']);
});
it('remove the first and last items', () => {
expect(remove(['a', 'b', 'c'], 0)).toEqual(['b', 'c']);
expect(remove(['a', 'b', 'c'], 2)).toEqual(['a', 'b']);
});
it('return a copy unchanged for an out-of-range index', () => {
const source = ['a', 'b'];
const result = remove(source, 9);
expect(result).toEqual(['a', 'b']);
expect(result).not.toBe(source);
});
it('never mutate the source', () => {
const source = ['a', 'b', 'c'];
remove(source, 1);
expect(source).toEqual(['a', 'b', 'c']);
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* @name remove
* @category Arrays
* @description Return a new array with the item at `index` removed. Returns a shallow
* copy unchanged when the index is out of range. Never mutates.
*
* @param {readonly T[]} array - The source array
* @param {number} index - Index of the item to remove
* @returns {T[]} A new array without the removed item
*
* @example
* remove(['a', 'b', 'c'], 1); // ['a', 'c']
*
* @since 0.0.10
*/
export function remove<T>(array: readonly T[], index: number): T[] {
const result = array.slice();
if (index < 0 || index >= result.length)
return result;
result.splice(index, 1);
return result;
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { swap } from '.';
describe('swap', () => {
it('swap two items', () => {
expect(swap(['a', 'b', 'c'], 0, 2)).toEqual(['c', 'b', 'a']);
});
it('return a copy unchanged when indices are equal', () => {
const source = ['a', 'b'];
const result = swap(source, 1, 1);
expect(result).toEqual(['a', 'b']);
expect(result).not.toBe(source);
});
it('return a copy unchanged for out-of-range indices', () => {
expect(swap(['a', 'b'], 0, 9)).toEqual(['a', 'b']);
expect(swap(['a', 'b'], -1, 1)).toEqual(['a', 'b']);
});
it('never mutate the source', () => {
const source = ['a', 'b', 'c'];
swap(source, 0, 2);
expect(source).toEqual(['a', 'b', 'c']);
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* @name swap
* @category Arrays
* @description Return a new array with the items at indices `a` and `b` swapped.
* Returns a shallow copy unchanged when either index is out of range or equal.
* Never mutates.
*
* @param {readonly T[]} array - The source array
* @param {number} a - First index
* @param {number} b - Second index
* @returns {T[]} A new array with the two items swapped
*
* @example
* swap(['a', 'b', 'c'], 0, 2); // ['c', 'b', 'a']
*
* @since 0.0.10
*/
export function swap<T>(array: readonly T[], a: number, b: number): T[] {
const result = array.slice();
if (a < 0 || b < 0 || a >= result.length || b >= result.length || a === b)
return result;
const temp = result[a] as T;
result[a] = result[b] as T;
result[b] = temp;
return result;
}
+1
View File
@@ -1 +1,2 @@
export * from './get';
export * from './set';
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { set } from '.';
describe('set', () => {
it('set a top-level property', () => {
expect(set({ name: 'John' }, 'name', 'Jane')).toEqual({ name: 'Jane' });
});
it('set a nested object property', () => {
expect(set({ user: { name: 'John' } }, 'user.name', 'Jane')).toEqual({ user: { name: 'Jane' } });
});
it('set through an array index', () => {
expect(set({ items: [{ id: 1 }, { id: 2 }] }, 'items.1.id', 9)).toEqual({ items: [{ id: 1 }, { id: 9 }] });
});
it('create missing object intermediates', () => {
expect(set({}, 'a.b.c', 42)).toEqual({ a: { b: { c: 42 } } });
});
it('create an array when the next segment is numeric', () => {
const result = set({} as Record<string, unknown>, 'items.0.id', 1);
expect(Array.isArray((result as any).items)).toBe(true);
expect(result).toEqual({ items: [{ id: 1 }] });
});
it('overwrite a non-object intermediate', () => {
expect(set({ a: 1 } as Record<string, unknown>, 'a.b', 2)).toEqual({ a: { b: 2 } });
});
it('mutate and return the same reference', () => {
const source = { a: 1 };
expect(set(source, 'a', 2)).toBe(source);
expect(source.a).toBe(2);
});
it('preserve falsy values', () => {
expect(set({}, 'count', 0)).toEqual({ count: 0 });
expect(set({}, 'flag', false)).toEqual({ flag: false });
});
it('return the object unchanged for an empty path', () => {
const source = { a: 1 };
expect(set(source, '', 2)).toBe(source);
expect(source).toEqual({ a: 1 });
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { Collection } from '../../types';
// Hoisted so it is compiled once rather than re-created on each `set` call.
const NUMERIC_SEGMENT = /^\d+$/;
/**
* @name set
* @category Collections
* @description Write a deeply nested value into a collection by a dot-separated path,
* creating any missing intermediate containers along the way. A numeric next segment
* creates an array, otherwise an object. Mutates and returns the original collection.
*
* @param {Collection} obj - The target object or array (mutated in place)
* @param {string} path - Dot-separated path, e.g. `'user.addresses.0.street'`
* @param {unknown} value - The value to assign at the path
* @returns {Collection} The same `obj`, for chaining
*
* @example
* set({ user: { name: 'John' } }, 'user.name', 'Jane'); // { user: { name: 'Jane' } }
* set({}, 'items.0.id', 1); // { items: [{ id: 1 }] }
*
* @since 0.0.10
*/
export function set<O extends Collection>(obj: O, path: string, value: unknown): O {
if (path === '')
return obj;
const keys = path.split('.');
const lastKey = keys[keys.length - 1]!;
let current: any = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]!;
const next = current[key];
// Create the missing intermediate: an array when the next segment is a numeric
// index, otherwise a plain object.
if (next === null || typeof next !== 'object')
current[key] = NUMERIC_SEGMENT.test(keys[i + 1]!) ? [] : {};
current = current[key];
}
current[lastKey] = value;
return obj;
}
+1
View File
@@ -0,0 +1 @@
export * from './isEqual';
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { isEqual } from '.';
describe('isEqual', () => {
it('compare primitives', () => {
expect(isEqual(1, 1)).toBe(true);
expect(isEqual('a', 'a')).toBe(true);
expect(isEqual(1, 2)).toBe(false);
expect(isEqual(1, '1')).toBe(false);
expect(isEqual(null, null)).toBe(true);
expect(isEqual(null, undefined)).toBe(false);
});
it('treat NaN as equal to NaN', () => {
expect(isEqual(Number.NaN, Number.NaN)).toBe(true);
expect(isEqual(Number.NaN, 1)).toBe(false);
});
it('compare arrays deeply', () => {
expect(isEqual([1, 2, 3], [1, 2, 3])).toBe(true);
expect(isEqual([1, [2, 3]], [1, [2, 3]])).toBe(true);
expect(isEqual([1, 2], [1, 2, 3])).toBe(false);
expect(isEqual([1, { a: 2 }], [1, { a: 3 }])).toBe(false);
});
it('compare plain objects deeply', () => {
expect(isEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(true);
expect(isEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
expect(isEqual({ a: 1 }, { a: 2 })).toBe(false);
});
it('compare dates and regexps', () => {
expect(isEqual(new Date(0), new Date(0))).toBe(true);
expect(isEqual(new Date(0), new Date(1))).toBe(false);
expect(isEqual(/a/gi, /a/gi)).toBe(true);
expect(isEqual(/a/g, /a/i)).toBe(false);
});
it('compare Map and Set', () => {
expect(isEqual(new Map([['a', 1]]), new Map([['a', 1]]))).toBe(true);
expect(isEqual(new Map([['a', 1]]), new Map([['a', 2]]))).toBe(false);
expect(isEqual(new Set([1, 2]), new Set([1, 2]))).toBe(true);
expect(isEqual(new Set([1, 2]), new Set([1, 3]))).toBe(false);
});
it('distinguish arrays from objects', () => {
expect(isEqual([], {})).toBe(false);
});
it('handle circular references', () => {
const a: any = { name: 'a' };
a.self = a;
const b: any = { name: 'a' };
b.self = b;
expect(isEqual(a, b)).toBe(true);
});
});
@@ -0,0 +1,95 @@
function equals(a: any, b: any, seen: WeakMap<object, unknown>): boolean {
if (a === b)
return true;
// NaN is the only value not equal to itself; treat NaN === NaN.
if (typeof a === 'number' && typeof b === 'number')
return Number.isNaN(a) && Number.isNaN(b);
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object')
return false;
// Cycle guard: if we have already paired `a`, it must pair with the same `b`.
const paired = seen.get(a);
if (paired !== undefined)
return paired === b;
seen.set(a, b);
if (a instanceof Date || b instanceof Date)
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
if (a instanceof RegExp || b instanceof RegExp)
return a instanceof RegExp && b instanceof RegExp && a.source === b.source && a.flags === b.flags;
const aIsArray = Array.isArray(a);
const bIsArray = Array.isArray(b);
if (aIsArray || bIsArray) {
if (!aIsArray || !bIsArray || a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++) {
if (!equals(a[i], b[i], seen))
return false;
}
return true;
}
if (a instanceof Map || b instanceof Map) {
if (!(a instanceof Map) || !(b instanceof Map) || a.size !== b.size)
return false;
for (const [key, value] of a) {
if (!b.has(key) || !equals(value, b.get(key), seen))
return false;
}
return true;
}
if (a instanceof Set || b instanceof Set) {
if (!(a instanceof Set) || !(b instanceof Set) || a.size !== b.size)
return false;
for (const value of a) {
if (!b.has(value))
return false;
}
return true;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length)
return false;
for (const key of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, key) || !equals(a[key], b[key], seen))
return false;
}
return true;
}
/**
* @name isEqual
* @category Comparators
* @description Deep structural equality between two values. Handles primitives
* (NaN-aware), `Date`, `RegExp`, arrays, `Map`, `Set`, and plain objects, and is
* safe against circular references. `Set` membership is compared shallowly.
*
* @param {unknown} a - The first value
* @param {unknown} b - The second value
* @returns {boolean} `true` if the values are deeply equal
*
* @example
* isEqual({ a: [1, 2] }, { a: [1, 2] }); // true
* isEqual([1, { b: 2 }], [1, { b: 3 }]); // false
* isEqual(Number.NaN, Number.NaN); // true
*
* @since 0.0.10
*/
export function isEqual(a: unknown, b: unknown): boolean {
return equals(a, b, new WeakMap());
}
+1
View File
@@ -2,6 +2,7 @@ export * from './arrays';
export * from './async';
export * from './bits';
export * from './collections';
export * from './comparators';
export * from './functions';
export * from './math';
export * from './objects';
@@ -31,7 +31,7 @@ export function levenshteinDistance(left: string, right: string): number {
for (let j = 1; j <= innerLength; j++) {
const cost = outerChar === inner[j - 1] ? 0 : 1;
current[j]! = Math.min(
current[j] = Math.min(
prev[j]! + 1, // insertion
current[j - 1]! + 1, // deletion
prev[j - 1]! + cost, // substitution
+3 -3
View File
@@ -15,7 +15,7 @@ export type TemplateFallback = string | ((key: string) => string);
/**
* Type of a template string with placeholders.
*/
const TEMPLATE_PLACEHOLDER = /\{\s*([^{}]+?)\s*\}/gm;
const TEMPLATE_PLACEHOLDER = /\{([^{}]+)\}/g;
/**
* Removes the placeholder syntax from a template string.
@@ -82,8 +82,8 @@ export function templateObject<
T extends string,
A extends GenerateTypes<ExtractPlaceholders<T>, TemplateValue> & Collection,
>(template: T, args: A, fallback?: TemplateFallback): string {
return template.replace(TEMPLATE_PLACEHOLDER, (_match, key: string) => {
const value = get(args, key);
return template.replaceAll(TEMPLATE_PLACEHOLDER, (_match, key: string) => {
const value = get(args, key.trim());
if (value !== null && value !== undefined)
return String(value);
+2
View File
@@ -38,6 +38,7 @@ describe('complex', () => {
});
it('true for class instances and null-prototype objects', () => {
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- fixture for the instance check
class Foo {}
expect(isObject(new Foo())).toBe(true);
@@ -48,6 +49,7 @@ describe('complex', () => {
describe('isRegExp', () => {
it('true if the value is a regexp', () => {
expect(isRegExp(/test/)).toBe(true);
// eslint-disable-next-line prefer-regex-literals -- intentionally testing the constructor form
expect(isRegExp(new RegExp('test'))).toBe(true);
});
+1 -1
View File
@@ -72,7 +72,7 @@ describe('primitives', () => {
describe('isSymbol', () => {
it('true if the value is a symbol', () => {
expect(isSymbol(Symbol())).toBe(true);
expect(isSymbol(Symbol('test'))).toBe(true);
});
it('false if the value is not a symbol', () => {
+1 -1
View File
@@ -80,7 +80,7 @@ export const isSymbol = (value: any): value is symbol => typeof value === 'symbo
*
* @since 0.0.2
*/
export const isUndefined = (value: any): value is undefined => typeof value === 'undefined';
export const isUndefined = (value: any): value is undefined => value === undefined;
/**
* @name isNull
+1 -1
View File
@@ -6,7 +6,7 @@ describe('string', () => {
it('should be a string', () => {
expectTypeOf(Number(1)).toExtend<Stringable>();
expectTypeOf(String(1)).toExtend<Stringable>();
expectTypeOf(Symbol()).toExtend<Stringable>();
expectTypeOf(Symbol('test')).toExtend<Stringable>();
expectTypeOf([1]).toExtend<Stringable>();
expectTypeOf(new Object()).toExtend<Stringable>();
expectTypeOf(new Date()).toExtend<Stringable>();