diff --git a/docs/typed/isEqual.mdx b/docs/typed/isEqual.mdx index 5ec375047..ed1e0ae73 100644 --- a/docs/typed/isEqual.mdx +++ b/docs/typed/isEqual.mdx @@ -1,20 +1,69 @@ --- title: isEqual -description: Determine if two values are equal +description: Determine if two values are deeply equal since: 12.1.0 --- ### Usage -Given two values, returns true if they are equal. +Returns `true` when two values are deeply equal. It starts with `Object.is()` and then performs additional comparisons for arrays, dates, regular expressions, and finally objects of any other type. ```ts import * as _ from 'radashi' -_.isEqual(null, null) // => true -_.isEqual([], []) // => true -_.isEqual({ hello: 'world' }, { hello: 'world' }) // => true +const left = { + id: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + tags: ['radashi', 'typed'], + [Symbol.for('role')]: 'admin', +} -_.isEqual('hello', 'world') // => false -_.isEqual(22, 'abc') // => false +const right = { + tags: ['radashi', 'typed'], + createdAt: new Date('2024-01-01T00:00:00.000Z'), + id: 1, + [Symbol.for('role')]: 'admin', +} + +_.isEqual(left, right) // => true + +_.isEqual({ id: 1 }, { id: 2 }) // => false +_.isEqual([1, 2, 3], [1, 2, 4]) // => false +_.isEqual(/hello/gi, /hello/g) // => false ``` + +Arrays are compared by length and element order, objects are compared recursively (including symbol keys), dates compare their timestamps, and regular expressions compare both pattern and flags. Instances must share the same prototype, and objects from other [realms](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof#instanceof_and_multiple_realms) are unequal by default. + +### Custom comparison + +Pass a `customCompare` function to handle types beyond the built-in cases. The function receives both values and may return: + +- `true` or `false` to override the result. +- `null`/`undefined` to fall back to the default behavior. + +The custom function is only called once arrays, plain objects, dates, and regular expressions have been ruled out. This makes it ideal for handling `Map`, `Set`, and domain-specific classes. + +```ts +const compareCollections = (left: unknown, right: unknown) => { + if (left instanceof Map) { + return _.isMapEqual(left, right as Map) + } + if (left instanceof Set) { + return _.isSetEqual(left, right as Set) + } + return null +} + +const a = new Map([[1, ['a', 'b']]]) +const b = new Map([[1, ['a', 'b']]]) + +_.isEqual(a, b, compareCollections) // => true +``` + +### Caveats + +- Sparse arrays are not supported. +- Cyclical structures are not supported. +- Differences in prototypes (including values from different realms) make objects unequal unless handled by a custom comparer. + +For dedicated helpers, see [`isMapEqual`](./isMapEqual) and [`isSetEqual`](./isSetEqual). diff --git a/src/typed/isEqual.ts b/src/typed/isEqual.ts index 6508f8298..3bb08862b 100644 --- a/src/typed/isEqual.ts +++ b/src/typed/isEqual.ts @@ -1,48 +1,77 @@ /** - * Return true if the given values are equal. + * Return true if the given values are deeply equal. * * To determine equality, `Object.is()` is used first. If it returns * false, we do the following special checks: * - `Date` and `Date` with the same time * - `RegExp` and `RegExp` with the same pattern/flags - * - object with the same keys and values (recursive) + * - arrays with the same length and elements (recursive) + * - objects with the same keys and values (recursive) + * + * You may pass a custom compare function to handle specific cases. + * Your compare function is called before the final object comparison, + * after all other checks. It can return `null` to default to the + * built-in behavior. + * + * See the documentation for caveats. * * @see https://radashi.js.org/reference/typed/isEqual - * @example - * ```ts - * isEqual(0, 0) // => true - * isEqual(0, 1) // => false - * ``` * @version 12.1.0 */ -export function isEqual(x: TType, y: TType): boolean { +export function isEqual( + x: T, + y: T, + customCompare?: (x: any, y: any) => boolean | null | undefined, +): boolean +export function isEqual( + x: any, + y: any, + customCompare?: (x: any, y: any) => boolean | null | undefined, +): boolean { if (Object.is(x, y)) { return true } - if (x instanceof Date && y instanceof Date) { - return x.getTime() === y.getTime() - } - if (x instanceof RegExp && y instanceof RegExp) { - return x.toString() === y.toString() - } if ( + !x || + !y || typeof x !== 'object' || - x === null || typeof y !== 'object' || - y === null + Object.getPrototypeOf(x) !== Object.getPrototypeOf(y) ) { return false } - const keysX = Reflect.ownKeys(x as unknown as object) as (keyof typeof x)[] - const keysY = Reflect.ownKeys(y as unknown as object) - if (keysX.length !== keysY.length) { + switch (x.constructor) { + case Object: + break + // Fast path for arrays + case Array: + return ( + x.length === y.length && + (x as any[]).every((item, index) => { + return isEqual(item, y[index]) + }) + ) + case Date: + return x.getTime() === y.getTime() + case RegExp: + return x.toString() === y.toString() + default: { + const result = customCompare?.(x, y) + if (result != null) { + return result + } + } + } + const kx = Reflect.ownKeys(x) + const ky = Reflect.ownKeys(y) + if (kx.length !== ky.length) { return false } - for (let i = 0; i < keysX.length; i++) { - if (!Reflect.has(y as unknown as object, keysX[i])) { - return false - } - if (!isEqual(x[keysX[i]], y[keysX[i]])) { + for (const key of kx as (keyof typeof x)[]) { + if ( + !Object.prototype.hasOwnProperty.call(y, key) || + !isEqual(x[key], y[key]) + ) { return false } } diff --git a/tests/typed/isEqual.test.ts b/tests/typed/isEqual.test.ts index 3d19b6845..225ed74ee 100644 --- a/tests/typed/isEqual.test.ts +++ b/tests/typed/isEqual.test.ts @@ -1,79 +1,212 @@ +import vm from 'node:vm' import * as _ from 'radashi' +const symbolKey = Symbol('symKey') +const sharedFunction = () => true + describe('isEqual', () => { - class Person { - name: string - friends: Person[] = [] - self?: Person - constructor(name: string) { - this.name = name - } - } - const jake = new Person('jake') - jake.self = jake - jake.friends = [jake, jake] - const symbolKey = Symbol('symKey') - const complex = { - num: 0, - str: '', - boolean: true, - unf: void 0, - nul: null, - obj: { name: 'object', id: 1, children: [0, 1, 2] }, - arr: [0, 1, 2], - func() { - return true - }, - loop: null as any, - person: jake, - date: new Date(0), - reg: /\/regexp\/ig/, - [symbolKey]: 'symbol', - } - complex.loop = complex - test('returns true for equal things', () => { - expect(_.isEqual(0, 0)).toBeTruthy() - expect(_.isEqual('a', 'a')).toBeTruthy() + test('returns true for equal primitives', () => { + expect(_.isEqual(0, 0)).toBe(true) + expect(_.isEqual('a', 'a')).toBe(true) + expect(_.isEqual(true, true)).toBe(true) + }) + + test('returns true for equal symbols', () => { const hello = Symbol('hello') - expect(_.isEqual(hello, hello)).toBeTruthy() - expect(_.isEqual({}, {})).toBeTruthy() - expect(_.isEqual(true, true)).toBeTruthy() - expect(_.isEqual(new RegExp(/a*s/), new RegExp(/a*s/))).toBeTruthy() + expect(_.isEqual(hello, hello)).toBe(true) + expect(_.isEqual(Symbol.for('hello'), Symbol.for('hello'))).toBe(true) + }) + + test('returns true for equal plain objects', () => { + expect(_.isEqual({}, {})).toBe(true) + expect(_.isEqual({ a: 1 }, { a: 1 })).toBe(true) + }) + + test('returns true for equal regular expressions', () => { + expect(_.isEqual(/a*s/, /a*s/)).toBe(true) + }) + + test('returns true for equal dates', () => { const now = new Date() - expect(_.isEqual(now, now)).toBeTruthy() - expect(_.isEqual([], [])).toBeTruthy() - expect(_.isEqual(complex, { ...complex })).toBeTruthy() - expect( - _.isEqual([complex, complex], [{ ...complex }, { ...complex }]), - ).toBeTruthy() - expect( - _.isEqual( - new Map([ - [1, 'one'], - [2, 'two'], - [3, 'three'], - ]), - new Map([ - [3, 'three'], - [2, 'two'], - [1, 'one'], - ]), - ), - ).toBeTruthy() - expect(_.isEqual(new Set([1, 2, 3]), new Set([3, 2, 1]))).toBeTruthy() - }) - test('returns false for non-equal things', () => { - expect(_.isEqual(0, 1)).toBeFalsy() - expect(_.isEqual('a', 'b')).toBeFalsy() - expect(_.isEqual(new RegExp(/^http:/), new RegExp(/https/))).toBeFalsy() - expect(_.isEqual(Symbol('hello'), Symbol('goodbye'))).toBeFalsy() - expect(_.isEqual({ z: 23 }, { a: 1 })).toBeFalsy() - expect(_.isEqual(true, false)).toBeFalsy() - expect( - _.isEqual(new Date(), new Date('2022-09-01T03:25:12.750Z')), - ).toBeFalsy() - expect(_.isEqual([], [1])).toBeFalsy() - expect(_.isEqual(complex, { ...complex, num: 222 })).toBeFalsy() - expect(_.isEqual([complex], [{ ...complex, num: 222 }])).toBeFalsy() + expect(_.isEqual(now, now)).toBe(true) + }) + + test('returns true for equal arrays', () => { + expect(_.isEqual([], [])).toBe(true) + expect(_.isEqual([1], [1])).toBe(true) + }) + + test('checks for deep object equality', () => { + const obj = { a: { b: { c: 1 } } } + const obj2 = structuredClone(obj) + expect(_.isEqual(obj, obj2)).toBe(true) + obj2.a.b.c = 2 + expect(_.isEqual(obj, obj2)).toBe(false) + }) + + test('checks for deep array equality', () => { + const arr = [1, [2, [3]]] as [number, [number, [number]]] + const arr2 = structuredClone(arr) + expect(_.isEqual(arr, arr2)).toBe(true) + arr2[1][1][0] = 4 + expect(_.isEqual(arr, arr2)).toBe(false) + }) + + test('returns true for prototype-less objects with same keys and values', () => { + const obj = Object.create(null) + obj.a = 1 + obj.b = 2 + const obj2 = Object.create(null) + obj2.a = 1 + obj2.b = 2 + expect(_.isEqual(obj, obj2)).toBe(true) + }) + + test('returns false for primitives of different values', () => { + expect(_.isEqual(0, 1)).toBe(false) + expect(_.isEqual('a', 'b')).toBe(false) + expect(_.isEqual(true, false)).toBe(false) + }) + + test('returns false for symbols created separately', () => { + expect(_.isEqual(Symbol('hello'), Symbol('hello'))).toBe(false) + }) + + test('returns false for objects with different properties', () => { + expect(_.isEqual({ a: 1 }, { a: 2 })).toBe(false) + expect(_.isEqual({ a: 1 }, { b: 1 })).toBe(false) + }) + + test('returns false for different regular expressions', () => { + expect(_.isEqual(/^http:/, /https/)).toBe(false) + expect(_.isEqual(/../g, /../)).toBe(false) + }) + + test('returns false for dates with different values', () => { + expect(_.isEqual(new Date(), new Date('2022-09-01T03:25:12.750Z'))).toBe( + false, + ) + }) + + test('returns false for arrays with different contents', () => { + expect(_.isEqual([], [1])).toBe(false) + expect(_.isEqual([1], [2])).toBe(false) + }) + + test('returns false when comparing RegExp with plain object', () => { + expect(_.isEqual(/a/, { lastIndex: 0 } as any)).toBe(false) + }) + + test('returns false when comparing Date with empty object', () => { + expect(_.isEqual(new Date(0), {} as any)).toBe(false) + }) + + test('returns false for objects with different constructors', () => { + expect(_.isEqual({}, Object.create(null))).toBe(false) + expect(_.isEqual({}, [])).toBe(false) + }) + + test('returns false for missing key even if values are undefined', () => { + expect(_.isEqual({ a: undefined }, { b: undefined })).toBe(false) + }) + + test('returns false for object with too many keys', () => { + expect(_.isEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false) + expect(_.isEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false) + }) + + describe('custom compare function', () => { + test('called for maps', () => { + const map1 = new Map([[1, 'a']]) + const map2 = new Map([[1, 'a']]) + const spy = vi.fn(() => true) + expect(_.isEqual(map1, map2, spy)).toBe(true) + expect(spy).toHaveBeenCalledWith(map1, map2) + }) + + test('called for sets', () => { + const set1 = new Set([1, 2]) + const set2 = new Set([1, 2]) + const spy = vi.fn(() => true) + expect(_.isEqual(set1, set2, spy)).toBe(true) + expect(spy).toHaveBeenCalledWith(set1, set2) + }) + + test('called for custom class instances', () => { + class Foo { + constructor(public x: number) {} + } + const foo1 = new Foo(1) + const foo2 = new Foo(1) + const spy = vi.fn(() => true) + expect(_.isEqual(foo1, foo2, spy)).toBe(true) + expect(spy).toHaveBeenCalledWith(foo1, foo2) + }) + + test('may return null (default to built-in behavior)', () => { + class Bar { + constructor(public y: number) {} + } + const bar1 = new Bar(2) + const bar2 = new Bar(2) + const spy = vi.fn(() => null) + expect(_.isEqual(bar1, bar2, spy)).toBe(true) + expect(spy).toHaveBeenCalledWith(bar1, bar2) + }) + + test('not called for plain object comparison', () => { + const obj1 = { a: 1 } + const obj2 = { a: 1 } + const spy = vi.fn() + expect(_.isEqual(obj1, obj2, spy)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) + + test('not called for primitive comparison', () => { + const spy = vi.fn() + expect(_.isEqual(1, 1, spy)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) + + test('not called for array comparison', () => { + const arr1 = [1, 2, 3] + const arr2 = [1, 2, 3] + const spy = vi.fn() + expect(_.isEqual(arr1, arr2, spy)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) + + test('not called for date comparison', () => { + const date1 = new Date(123) + const date2 = new Date(123) + const spy = vi.fn() + expect(_.isEqual(date1, date2, spy)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) + + test('not called for regexp comparison', () => { + const re1 = /abc/gi + const re2 = /abc/gi + const spy = vi.fn() + expect(_.isEqual(re1, re2, spy)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) + }) + + describe('unsupported cases', () => { + test('sparse arrays', () => { + // biome-ignore lint/suspicious/noSparseArray: + expect(_.isEqual([1, , 3], [1, 2, 3])).toBe(true) + }) + + test('objects from different realms', () => { + const context = vm.createContext() + // Create an object in the VM context (different realm) + const objFromVm = vm.runInContext('({ a: 1 })', context) + // Create a similar object in the main context + const obj = { a: 1 } + // They should not be considered equal due to different realms (constructors differ) + expect(_.isEqual(obj, objFromVm)).toBe(false) + }) }) })