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:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user