Skip to content

Instantly share code, notes, and snippets.

@DLiblik
Created March 15, 2024 18:35
Show Gist options
  • Save DLiblik/80ab3179e59ee77f06d6ea22c71273e5 to your computer and use it in GitHub Desktop.
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.
/**
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