Is there a better way to deeply compare two objects?

390 Views Asked by At

I would like to know if there is a better way to compare objects in JavaScript.

I understand that there are many large solutions but I would like to get a clear answer if their is a simple way to do this without an external lib. Possibly using one of the newer APIs.

I also understand it is likely that the answer is simply: no, there is no better solution yet.

This is the base of the example I have:


const a = {value: 'foo'};
const b = {value: 'foo'};

function isEqual(obj1, obj2) {
 // This function is the only being changed in the examples
 // It should return a boolean
 return // TODO
}

I know this does not work because its comparing the memory reference:

function isEqual(obj1, obj2) {
 return obj1 === obj2;
}
console.log(isEqual(a, b)); // false

However, I can do this:

function isEqual(obj1, obj2) {
 return JSON.stringify(obj1) === JSON.stringify(obj2);
}
console.log(isEqual(a, b)); // true

JavaScript has come a long way so can it do this natively. I know you can so it using external libs like lodash, but i would like ot know if there is a better way using modern JavaScript.

I attempted to investigate it and did not find a valid solution, here are some links related to this topic.

3

There are 3 best solutions below

4
trincot On BEST ANSWER

The Q&A you refer to include also solutions in plain JavaScript without external libraries, but they differ in several ways. Which one to choose is a matter of opinion, and depends on the actual need you have. The thing is that equality is a fuzzy concept. Some will regard some objects the same while others wouldn't, and so choices have to be made.

Some examples, just scratching the surface, of what could be considered equal or not, depending on your need/expectation:

  • new Set([{}]) and new Set([{}]): if they are regarded equal, note how the member of the first set is not in the second.
  • function () { } and function () { }
  • { a: 1, b: 2} and { b: 2, a: 1 }: if we regard a plain object as a collection of key/value pairs that have no order, then you would regard these the same, but maybe you have an algorithm that relies on the insertion order of non-array-index keys (whether that is good practice is debatable), and then these objects should not be regarded the same.
  • {} and new Proxy({}, {})
  • When a=[1,2,3], then consider a.values() and a.values(): two iterators on the same array.
  • /a/g and /a/g, recalling that regexes have state (lastIndex).
  • document.createTextElement("") and document.createTextElement(""): If considered the same, they surely stop being that when one of both gets inserted in the document.
  • new MyClass() and new MyClass() when they have a private field that is initialised in the constructor as a random value. As such field defines the object state, but cannot be queried by a function outside the class's definition, you cannot really decide on equality.
  • ... one could come up with a lot more examples.

In conclusion, you'd need to make quite a few decisions, which could include the decision that your function will only need to work for a limited set of "types" of objects.

1
Carl On

note: please use npm deep-sort-object package, please visit github.

function defaultSortFn(a, b) {
    return a.localeCompare(b);
}

function isPlainObject(src) {
    return Object.prototype.toString.call(src) === '[object Object]';
}

function deepSortObject(src, comparator) {
    var out;
    
    if (Array.isArray(src)) {
        return src.map(function (item) {
            return deepSortObject(item, comparator);
        });
    }
    
    if (isPlainObject(src)) {
        out = {};
        
        Object.keys(src).sort(comparator || defaultSortFn).forEach(function (key) {
            out[key] = deepSortObject(src[key], comparator);
        });
        
        return out;
    }
    
    return src;
}

const o1 = {
    'z': 'foo',
    'b': 'bar',
    'a': [
        {
            'z': 'foo',
            'b': 'bar'
        }
    ]
};

const o2 = {
    'b': 'bar',
    'z': 'foo',
    'a': [
        {
            'b': 'bar',
            'z': 'foo',
        }
    ]
}

const strO1 = JSON.stringify(deepSortObject(o1));
const strO2 = JSON.stringify(deepSortObject(o2));

console.log(strO1 === strO2);

0
Peter Seliger On

A not that rhetorical question from my above comment ...

@Chris ... taking into account all the advices/answers, will the to be chosen approach now focus more on a) efficiency or on b) modularity/testability/maintainability, even runtime customization or c) assuring both without sacrificing too much of a)?

A generic implementation that starts with core-functionality which covers expectable main use-cases but is open for plugged-in extensions from both sides, maintainer and consumers, would be implemented around a single function and a lookup-table.

The function would check each of the two participants' internal type-names, which get derived from a value's/object's internal type-signature ... something like Object, Array, Set, Map, Date, RegExp, Boolean, Number, String, etc .., whereas the map-based lookup-table holds and provides functionality which implements the equality check of two provided values like array versus array, regex versus regex, but also special cases like comparing a bigint value to a number value or number object.

For the latter case the core function would even assure and pass the correct arguments precedence of the two to be compared values to the comparison function. Thus, such comparison functions can be implemented effortless regarding type- and precedence-checking of its two parameters.

Since the comparison functions get provided via a default lookup-table, it is up to the maintainer how to broaden/narrow the terms equality or sameness when it comes to the interpretation/implementation of certain value's/object's equality comparison. A lookup could be provided like e.g. follows ...

const comparisonLookup = new Map([
  ['Object_Object', isStructurallyEqualObjectObjects],
  ['Array_Array', isStructurallyEqualArrays],
 
  ['Map_Map', isDeepEqualMaps],
  ['Set_Set', isDeepEqualSets],

  ['Date_Date', isEqualDates],
  ['RegExp_RegExp', isEqualRegExps],

  ['Boolean_Boolean', isStructurallyEqualBooleans],
  ['String_String', isStructurallyEqualStrings],
  ['Number_Number', isStructurallyEqualNumbers],

  // ['BigInt_Number', isBigIntEqualToNumberValue],
]);

... thus, it can be extended at any given time by the maintainer/s. In the same time the core function (named e.g. isDeepStructuralEquality) would not only accept the two to be compared parameters a and b but an additional but optional map-based custom lookup-table as well. With the implementation of this core function one also decides whether and/or for which cases the custom comparison is allowed to overrule default comparison functionality.

A core function implementation then could look like follows ...

function isDeepStructuralEquality(a, b, customComparisonLookup = new Map) {
  let isEqual = Object.is(a, b);

  if (!isEqual) {
    const typeA = getTypeName(a);
    const typeB = getTypeName(b);

    const comparisonKey = [typeA, typeB].sort().join('_');

    const equalityComparison = (comparisonLookup
      .get(comparisonKey) ?? customComparisonLookup
      .get(comparisonKey)) ?? (() => false);

    const argsPrecedence = comparisonKey.startsWith([typeA, ''].join('_'))
      && [a, b]
      || [b, a];

    isEqual = equalityComparison(...argsPrecedence, customComparisonLookup);
  }
  return isEqual;
}

... and the accompanying entire core functionality comes as ...

function isStructurallyEqualObjectObjects(a, b, customComparisonLookup) {
  const aKeys = Object.keys(a);
  const bKeys = Object.keys(b);

  return (
    aKeys.length === bKeys.length &&
    aKeys.every((key, idx) =>

      isDeepStructuralEquality(a[key], b[key], customComparisonLookup)
    )
  );
}
function isStructurallyEqualArrays(a, b, customComparisonLookup) {
  return (
    a.length === b.length &&
    a.every((item, idx) =>

      isDeepStructuralEquality(item, b[idx], customComparisonLookup)
    )
  );
}

function isDeepEqualMaps(a, b, customComparisonLookup) {
  return (
    (a.size === b.size) &&
    [...a.entries()]
      .every(([key, value]) =>

        b.has(key) &&
        isDeepStructuralEquality(value, b.get(key), customComparisonLookup)
      )
  );
}
function isDeepEqualSets(a, b) {
  return (
    (a.size === b.size) &&
    [...a.keys()]
      .every(key => b.has(key))
  );
}

function isEqualDates(a, b) {
  return a.getTime() === b.getTime();
}
function isEqualRegExps(a, b) {
  return (
    (a.source === b.source) &&
    (a.flags.split('').sort().join('') === b.flags.split('').sort().join(''))
  );
}

function isStructurallyEqualBooleans(a, b) {
  const isPrimitiveA = (typeof a === 'boolean');
  const isPrimitiveB = (typeof b === 'boolean');

  return (
    // - does not equal when comparing an
    //   object against a primitive value.
    isPrimitiveA === isPrimitiveB &&
    Boolean(a) === Boolean(b)
  );
}
function isStructurallyEqualStrings(a, b) {
  const isPrimitiveA = (typeof a === 'string');
  const isPrimitiveB = (typeof b === 'string');

  return (
    // - does not equal when comparing an
    //   object against a primitive value.
    isPrimitiveA === isPrimitiveB &&
    String(a) === String(b)
  );
}
function isStructurallyEqualNumbers(a, b) {
  const isPrimitiveA = (typeof a === 'number');
  const isPrimitiveB = (typeof b === 'number');

  return (
    // - does not equal when comparing an
    //   object against a primitive value.
    isPrimitiveA === isPrimitiveB &&
    Number(a) === Number(b)
  );
}

// - the arguments precedence of functions which compare two
//   values of different types, follows the alphabetical order
//   of the participating values' types ... e.g. `BigInt`, `Number`
function isBigIntEqualToNumberValue(bigInt, number) {
  const isNumberValue = (typeof number === 'number');

  // - does not equal when comparing a
  //   bigint value against a number object.
  return isNumberValue && (Number(bigInt) === number);
}

const comparisonLookup = new Map([
  ['Object_Object', isStructurallyEqualObjectObjects],
  ['Array_Array', isStructurallyEqualArrays],
 
  ['Map_Map', isDeepEqualMaps],
  ['Set_Set', isDeepEqualSets],

  ['Date_Date', isEqualDates],
  ['RegExp_RegExp', isEqualRegExps],

  ['Boolean_Boolean', isStructurallyEqualBooleans],
  ['String_String', isStructurallyEqualStrings],
  ['Number_Number', isStructurallyEqualNumbers],

  // ['BigInt_Number', isBigIntEqualToNumberValue],
]);

function isDeepStructuralEquality(a, b, customComparisonLookup = new Map) {
  let isEqual = Object.is(a, b);

  if (!isEqual) {
    const typeA = getTypeName(a);
    const typeB = getTypeName(b);

    const comparisonKey = [typeA, typeB].sort().join('_');

    const equalityComparison = (comparisonLookup
      .get(comparisonKey) ?? customComparisonLookup
      .get(comparisonKey)) ?? (() => false);

    const argsPrecedence = comparisonKey.startsWith([typeA, ''].join('_'))
      && [a, b]
      || [b, a];

    isEqual = equalityComparison(...argsPrecedence, customComparisonLookup);
  }
  return isEqual;
}
const m1 = new Map([['foo', 'foo'], ['bar', 'bar']]);
const m2 = new Map([['foo', 'foo'], ['bar', 'bar']]);

const s1 = new Set(['foo', 'bar', m1, m2]);
const s2 = new Set(['foo', 'bar', m1, m2]);

console.log(
  'Map instance comparison ... expected: true ... is:',
  isDeepStructuralEquality(m1, m2)
);
console.log(
  'Set instance comparison ... expected: true ... is:',
  isDeepStructuralEquality(s1, s2)
);

console.log(
  '\nSet instance comparison ... expected: false ... is:',
  isDeepStructuralEquality(s1, new Map([['foo', 'foo'], ['bar', 'bar']]))
);

console.log(
  '\nisDeepStructuralEquality({ num: BigInt(0) }, { num: 0 }) ...',
  isDeepStructuralEquality({ num: BigInt(0) }, { num: 0 })
);
console.log(
`\nisDeepStructuralEquality(
  { num: BigInt(0) }, { num: 0 }, new Map([
    ['BigInt_Number', isBigIntEqualToNumberValue],
  ])
) ...`,
  isDeepStructuralEquality(
    { num: BigInt(0) }, { num: 0 }, new Map([
      ['BigInt_Number', isBigIntEqualToNumberValue],
    ])
  )
);
console.log(
`\nisDeepStructuralEquality(
  { num: BigInt(0) }, { num: new Number(0) }, new Map([
    ['BigInt_Number', isBigIntEqualToNumberValue],
  ])
) ...`,
  isDeepStructuralEquality(
    { num: BigInt(0) }, { num: new Number(0) }, new Map([
      ['BigInt_Number', isBigIntEqualToNumberValue],
    ])
  )
);
.as-console-wrapper { min-height: 100%!important; top: 0; }
<script>
// utility/helper functions module.

function getInternalTypeSignature(value) {
  return Object.prototype.toString.call(value).trim();
}

function getFunctionSignature(value) {
  return Function.prototype.toString.call(value).trim();
}
function getFunctionName(value) {
  return Object.getOwnPropertyDescriptor(value, 'name').value;
  // return value.name;
}

function getTypeName(value) {
  const regXInternalTypeName = /^\[object\s+(?<name>.*)]$/;

  let { name } = regXInternalTypeName.exec(
    getInternalTypeSignature(value),
  )?.groups;

  if (name === 'Object') {
    const { constructor } = Object.getPrototypeOf(value);
    if (
      typeof constructor === 'function' &&
      getFunctionSignature(constructor).startsWith('class ')
    ) {
      name = getFunctionName(constructor);
    }
  } else if (name === 'Error') {
    name = getFunctionName(Object.getPrototypeOf(value).constructor);
  }
  return name;
}
</script>