Created
March 15, 2024 18:35
-
-
Save DLiblik/80ab3179e59ee77f06d6ea22c71273e5 to your computer and use it in GitHub Desktop.
Compares two JS (TS) objects for equivalency of values. Priority is on speed of execution. Safe for objects with circular references.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
Fast compare of two items (values or references) for nested equivalency. | |
Does NOT use stringify and is robust to key sequence. | |
Fast fails on first difference found. | |
Equivalence means that at root and at each key or index they are equivalent | |
as follows: | |
- If a value type, values are either hard equal (===) or are both NaN | |
(different than JS where NaN !== NaN) | |
- If functions, they are the same function instance or have the same value | |
when converted to string via `toString()` | |
- If Date objects, both have the same getTime() or are both NaN (invalid) | |
- If arrays, both are same length, and all contained values areEquivalent | |
recursively - only contents by numeric key are checked | |
- If other object types, enumerable keys are the same (the keys themselves) | |
and values at every key areEquivalent recursively | |
@param value1 First item to compare | |
@param value2 Other item to compare | |
@param stack Used internally to track circular refs - don't set it | |
*/ | |
export function areEquivalent(value1:any, value2:any, stack:any[]=[]) { | |
if (Object.is(value1, value2)) { | |
return true; | |
} | |
// Numbers, strings, null, undefined, symbols, functions, booleans. | |
// Also: objects (incl. arrays) that are actually the same instance | |
if (value1 === value2) { | |
// Fast and done | |
return true; | |
} | |
if (value1 === null || value1 === undefined || | |
value2 === null || value2 === undefined) | |
{ | |
// If value1 or value2 is `null` or `undefined` and is not equal | |
// to the other (established above) then values are not equivalent. | |
return false; | |
} | |
const type1 = typeof value1; | |
// Ensure types match | |
if (type1 !== typeof value2) { | |
return false; | |
} | |
// Special case for function: check for toString() equivalence | |
if (type1 === 'function') { | |
// Failed initial equals test, but could still have equivalent | |
// implementations - note, will match on functions that have same name | |
// and are native code: `function abc() { [native code] }` | |
return value1.toString() === value2.toString(); | |
} | |
// For these types, cannot still be equal at this point, so fast-fail | |
if (type1 === 'number' || type1 === 'bigint' || type1 === 'boolean' || | |
type1 === 'string' || type1 === 'symbol') | |
{ | |
return false; | |
} | |
// For dates, cast to number and ensure equal or both NaN (note, if same | |
// exact instance then we're not here - that was checked above) | |
if (value1 instanceof Date) { | |
if (!(value2 instanceof Date)) { | |
return false; | |
} | |
// Convert to number to compare | |
const asNum1 = +value1, asNum2 = +value2; | |
// Check if both invalid (NaN) or are same value | |
return asNum1 === asNum2 || (isNaN(asNum1) && isNaN(asNum2)); | |
} | |
// At this point, it's a reference type and could be circular, so | |
// make sure we haven't been here before... note we only need to track value1 | |
// since value1 being un-circular means value2 will either be equal (and not | |
// circular too) or unequal whether circular or not. | |
if (stack.includes(value1)) { | |
throw new Error(`areEquivalent value1 is circular`); | |
} | |
// breadcrumb | |
stack.push(value1); | |
// Handle arrays | |
if (Array.isArray(value1)) { | |
if (!Array.isArray(value2)) { | |
return false; | |
} | |
const length = value1.length; | |
if (length !== value2.length) { | |
return false; | |
} | |
for (let i=0; i < length; i++) { | |
if (!areEquivalent(value1[i], value2[i], stack)) { | |
return false; | |
} | |
} | |
return true; | |
} | |
// Final case: object | |
// get both key lists and check length | |
const keys1 = Object.keys(value1); | |
const keys2 = Object.keys(value2); | |
const numKeys = keys1.length; | |
if (keys2.length !== numKeys) { | |
return false; | |
} | |
// Empty object on both sides? | |
if (numKeys === 0) { | |
return true; | |
} | |
// sort is a native call so it's very fast - much faster than comparing the | |
// values at each key if it can be avoided, so do the sort and then | |
// ensure every key matches at every index | |
keys1.sort(); | |
keys2.sort(); | |
// Ensure perfect match across all keys | |
for(let i = 0; i < numKeys; i++) { | |
if (keys1[i] !== keys2[i]) { | |
return false; | |
} | |
} | |
// Ensure perfect match across all values | |
for(let i = 0; i < numKeys; i++) { | |
if (!areEquivalent(value1[keys1[i]], value2[keys1[i]], stack)) { | |
return false; | |
} | |
} | |
// back up | |
stack.pop(); | |
// Walk the same, talk the same - matching ducks. Quack. | |
// 🦆🦆 | |
return true; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment