Repository: remeda/remeda
Pull request #565 (2024-03-10 10:24:47)

feat(isDeepEqual): replace `equals` with `isDeepEqual`
Largely copied from `equals` and then modified to:
* Enforcing the types overlap so that changes that would cause the function always to return `false` would be detected as compile errors.
* Making the function a guard against the second param, allowing it to be used as a type-narrower against the second param.
* Checking the object prototype, identifying mismatches early on, and taking advantage of this to perform fewer checks.

---

Make sure that you:

- [x] Typedoc added for new methods and updated for changed
- [x] Tests added for new methods and updated for changed
- [x] New methods added to `src/index.ts`
- [x] New methods added to `mapping.md`

---

<details><summary>We use semantic PR titles to automate the release process!</summary>

https://conventionalcommits.org

PRs should be titled following using the format: `< TYPE >(< scope >)?: description`

### Available Types:

- `feat`: new functions, and changes to a function's type that would impact users.
- `fix`: changes to the runtime behavior of an existing function, or refinements to it's type that shouldn't impact most users.
- `perf`: changes to function implementations that improve a functions _runtime_ performance.
- `refactor`: changes to function implementations that are neither `fix` nor `perf`
- `test`: tests-only changes (transparent to users of the function).
- `docs`: changes to the documentation of a function **or the documentation site**.
- `build`, `ci`, `style`, `chore`, and `revert`: are only relevant for the internals of the library.

For scope put the name of the function you are working on (either new or
existing).

</details>


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/remeda/remeda/pr/565/builds/482002) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 13020512e80143792fc3b12dab929c6d8cee8e0c:

|Sandbox| Source |
|--|--|
|[remeda-example-vanilla](https://codesandbox.io/s/remeda-example-vanilla-87t93d)| Configuration |


Patch:
diff --git a/mapping.md b/mapping.md
index 52f57b0..d2ba6e1 100644
--- a/mapping.md
+++ b/mapping.md
@@ -27,7 +27,6 @@ documentation when migrating._
 | [`dropLast`](https://remedajs.com/docs/#dropLast)                   | [`dropRight`](https://lodash.com/docs/4.17.15#dropRight)                 | [`dropLast`](https://ramdajs.com/docs/#dropLast)                   |
 | [`dropLastWhile`](https://remedajs.com/docs/#dropLastWhile)         | [`dropRightWhile`](https://lodash.com/docs/4.17.15#dropRightWhile)       | [`dropLastWhile`](https://ramdajs.com/docs/#dropLastWhile)         |
 | [`dropWhile`](https://remedajs.com/docs/#dropWhile)                 | [`dropWhile`](https://lodash.com/docs/4.17.15#dropWhile)                 | [`dropWhile`](https://ramdajs.com/docs/#dropWhile)                 |
-| [`equals`](https://remedajs.com/docs/#equals)                       | [`isEqual`](https://lodash.com/docs/4.17.15#isEqual)                     | [`equals`](https://ramdajs.com/docs/#equals)                       |
 | [`evolve`](https://remedajs.com/docs/#evolve)                       |                                                                          | [`evolve`](https://ramdajs.com/docs/#evolve)                       |
 | [`filter`](https://remedajs.com/docs/#filter)                       | [`filter`](https://lodash.com/docs/4.17.15#filter)                       | [`filter`](https://ramdajs.com/docs/#filter)                       |
 | [`find`](https://remedajs.com/docs/#find)                           | [`find`](https://lodash.com/docs/4.17.15#find)                           | [`find`](https://ramdajs.com/docs/#find)                           |
@@ -47,6 +46,7 @@ documentation when migrating._
 | [`intersection`](https://remedajs.com/docs/#intersection)           | [`intersection`](https://lodash.com/docs/4.17.15#intersection)           | [`intersection`](https://ramdajs.com/docs/#intersection)           |
 | [`intersectionWith`](https://remedajs.com/docs/#intersectionWith)   | [`intersectionWith`](https://lodash.com/docs/4.17.15#intersectionWith)   | [`innerJoin`](https://ramdajs.com/docs/#innerJoin)                 |
 | [`invert`](https://remedajs.com/docs/#invert)                       | [`invert`](https://lodash.com/docs/4.17.15#invert)                       | [`invertObj`](https://ramdajs.com/docs/#invertObj)                 |
+| [`isDeepEqual`](https://remedajs.com/docs/#isDeepEqual)             | [`isEqual`](https://lodash.com/docs/4.17.15#isEqual)                     | [`equals`](https://ramdajs.com/docs/#equals)                       |
 | [`isDefined`](https://remedajs.com/docs/#isDefined)                 |                                                                          | [`isNotNil`](https://ramdajs.com/docs/#isNotNil)                   |
 | [`isEmpty`](https://remedajs.com/docs/#isEmpty)                     | [`isEmpty`](https://lodash.com/docs/4.17.15#isEmpty)                     | [`isEmpty`](https://ramdajs.com/docs/#isEmpty)                     |
 | [`isNil`](https://remedajs.com/docs/#isNil)                         | [`isNil`](https://lodash.com/docs/4.17.15#isNil)                         | [`isNil`](https://ramdajs.com/docs/#isNil)                         |
diff --git a/src/equals.ts b/src/equals.ts
index 4fffc41..35ea98d 100644
--- a/src/equals.ts
+++ b/src/equals.ts
@@ -4,6 +4,8 @@ import { purry } from "./purry";
  * Returns true if its arguments are equivalent, false otherwise.
  * NOTE: Doesn't handle cyclical data structures.
  *
+ * **DEPRECATED: use R.isDeepEqual().**.
+ *
  * @param a - The first object to compare.
  * @param b - The second object to compare.
  * @signature
@@ -14,6 +16,7 @@ import { purry } from "./purry";
  *    R.equals([1, 2, 3], [1, 2, 3]) //=> true
  * @dataFirst
  * @category Object
+ * @deprecated Use `R.isDeepEqual`.
  */
 export function equals(a: unknown, b: unknown): boolean;
 
@@ -21,6 +24,8 @@ export function equals(a: unknown, b: unknown): boolean;
  * Returns true if its arguments are equivalent, false otherwise.
  * NOTE: Doesn't handle cyclical data structures.
  *
+ * **DEPRECATED: use R.isDeepEqual().**.
+ *
  * @param a - The first object to compare.
  * @param b - The second object to compare.
  * @signature
@@ -31,6 +36,7 @@ export function equals(a: unknown, b: unknown): boolean;
  *    R.equals([1, 2, 3])([1, 2, 3]) //=> true
  * @dataLast
  * @category Object
+ * @deprecated Use `R.isDeepEqual`.
  */
 export function equals(a: unknown): (b: unknown) => boolean;
 
diff --git a/src/index.ts b/src/index.ts
index ec584a8..c556fa3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -47,6 +47,7 @@ export * from "./invert";
 export * from "./isArray";
 export * from "./isBoolean";
 export * from "./isDate";
+export * from "./isDeepEqual";
 export * from "./isDefined";
 export * from "./isEmpty";
 export * from "./isError";
diff --git a/src/isDeepEqual.ts b/src/isDeepEqual.ts
new file mode 100644
index 0000000..95ef5f2
--- /dev/null
+++ b/src/isDeepEqual.ts
@@ -0,0 +1,144 @@
+import { purry } from "./purry";
+
+/**
+ * Performs a deep *semantic* comparison between two values to determine if they
+ * are equivalent. For primitive values this is equivalent to `===`, for arrays
+ * the check would be performed on every item recursively, in order, and for
+ * objects all props will be compared recursively. The built-in Date and RegExp
+ * are special-cased and will be compared by their values.
+ *
+ * !IMPORTANT: Maps, Sets and TypedArrays, and symbol properties of objects  are
+ * not supported right now and might result in unexpected behavior. Please open
+ * an issue in the Remeda github project if you need support for these types.
+ *
+ * The result would be narrowed to the second value so that the function can be
+ * used as a type guard.
+ *
+ * @param data - The first value to compare.
+ * @param other - The second value to compare.
+ * @signature
+ *    R.isDeepEqual(data, other)
+ * @example
+ *    R.isDeepEqual(1, 1) //=> true
+ *    R.isDeepEqual(1, '1') //=> false
+ *    R.isDeepEqual([1, 2, 3], [1, 2, 3]) //=> true
+ * @dataFirst
+ * @category Guard
+ */
+export function isDeepEqual<T, S extends T = T>(data: T, other: S): data is S;
+
+/**
+ * Performs a deep *semantic* comparison between two values to determine if they
+ * are equivalent. For primitive values this is equivalent to `===`, for arrays
+ * the check would be performed on every item recursively, in order, and for
+ * objects all props will be compared recursively. The built-in Date and RegExp
+ * are special-cased and will be compared by their values.
+ *
+ * !IMPORTANT: Maps, Sets and TypedArrays, and symbol properties of objects  are
+ * not supported right now and might result in unexpected behavior. Please open
+ * an issue in the Remeda github project if you need support for these types.
+ *
+ * The result would be narrowed to the second value so that the function can be
+ * used as a type guard.
+ *
+ * @param data - The first value to compare.
+ * @param other - The second value to compare.
+ * @signature
+ *    R.isDeepEqual(other)(data)
+ * @example
+ *    R.pipe(1, R.isDeepEqual(1)); //=> true
+ *    R.pipe(1, R.isDeepEqual('1')); //=> false
+ *    R.pipe([1, 2, 3], R.isDeepEqual([1, 2, 3])); //=> true
+ * @dataLast
+ * @category Guard
+ */
+export function isDeepEqual<T, S extends T = T>(
+  other: S,
+): (data: T) => data is S;
+
+export function isDeepEqual(): unknown {
+  return purry(isDeepEqualImplementation, arguments);
+}
+
+function isDeepEqualImplementation<T, S>(data: S | T, other: S): data is S {
+  if (data === other) {
+    return true;
+  }
+
+  if (typeof data === "number" && typeof other === "number") {
+    // TODO: This is a temporary fix for NaN, we should use Number.isNaN once we bump our target above ES5.
+    // eslint-disable-next-line no-self-compare -- We should use Number.isNaN here, but it's ES2015.
+    return data !== data && other !== other;
+  }
+
+  if (typeof data !== "object" || typeof other !== "object") {
+    return false;
+  }
+
+  if (data === null || other === null) {
+    return false;
+  }
+
+  if (Object.getPrototypeOf(data) !== Object.getPrototypeOf(other)) {
+    // If the objects don't share a prototype it's unlikely that they are
+    // semantically equal. It is technically possible to build 2 prototypes that
+    // act the same but are not equal (at the reference level, checked via
+    // `===`) and then create 2 objects that are equal although we would fail on
+    // them. Because this is so unlikely, the optimization we gain here for the
+    // rest of the function by assuming that `other` is of the same type as
+    // `data` is more than worth it.
+    return false;
+  }
+
+  if (Array.isArray(data)) {
+    if (data.length !== (other as ReadonlyArray<unknown>).length) {
+      return false;
+    }
+
+    for (let i = 0; i < data.length; i++) {
+      if (
+        !isDeepEqualImplementation(
+          data[i],
+          (other as ReadonlyArray<unknown>)[i],
+        )
+      ) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  if (data instanceof Date) {
+    return data.getTime() === (other as unknown as Date).getTime();
+  }
+
+  if (data instanceof RegExp) {
+    return data.toString() === (other as unknown as RegExp).toString();
+  }
+
+  // At this point we only know that the 2 objects share a prototype and are not
+  // any of the previous types. They could be plain objects (Object.prototype),
+  // they could be classes, they could be other built-ins, or they could be
+  // something weird. We assume that comparing values by keys is enough to judge
+  // their equality.
+
+  const keys = Object.keys(data);
+
+  if (keys.length !== Object.keys(other).length) {
+    return false;
+  }
+
+  for (const key of keys) {
+    if (!Object.prototype.hasOwnProperty.call(other, key)) {
+      return false;
+    }
+
+    // @ts-expect-error [ts7053] - There's no easy way to tell typescript these keys are safe.
+    if (!isDeepEqualImplementation(data[key], other[key])) {
+      return false;
+    }
+  }
+
+  return true;
+}


Test patch:
diff --git a/src/isDeepEqual.test.ts b/src/isDeepEqual.test.ts
new file mode 100644
index 0000000..36b9177
--- /dev/null
+++ b/src/isDeepEqual.test.ts
@@ -0,0 +1,287 @@
+import { isDeepEqual } from "./isDeepEqual";
+
+function func1(): void {
+  // (intentionally empty)
+}
+function func2(): void {
+  // (intentionally empty)
+}
+
+describe("scalars", () => {
+  test("equal numbers", () => {
+    expect(isDeepEqual(1, 1)).toBe(true);
+  });
+  test("not equal numbers", () => {
+    expect(isDeepEqual(1, 2)).toBe(false);
+  });
+  test("number and array are not equal", () => {
+    expect(isDeepEqual(1 as unknown, [])).toBe(false);
+  });
+  test("0 and null are not equal", () => {
+    expect(isDeepEqual(0 as unknown, null)).toBe(false);
+  });
+  test("equal strings", () => {
+    expect(isDeepEqual("a", "a")).toBe(true);
+  });
+  test("not equal strings", () => {
+    expect(isDeepEqual("a", "b")).toBe(false);
+  });
+  test("empty string and null are not equal", () => {
+    expect(isDeepEqual("" as unknown, null)).toBe(false);
+  });
+  test("null is equal to null", () => {
+    expect(isDeepEqual(null, null)).toBe(true);
+  });
+  test("equal booleans (true)", () => {
+    expect(isDeepEqual(true, true)).toBe(true);
+  });
+  test("equal booleans (false)", () => {
+    expect(isDeepEqual(false, false)).toBe(true);
+  });
+  test("not equal booleans", () => {
+    expect(isDeepEqual(true, false)).toBe(false);
+  });
+  test("1 and true are not equal", () => {
+    expect(isDeepEqual(1 as unknown, true)).toBe(false);
+  });
+  test("0 and false are not equal", () => {
+    expect(isDeepEqual(0 as unknown, false)).toBe(false);
+  });
+  test("NaN and NaN are equal", () => {
+    expect(isDeepEqual(Number.NaN, Number.NaN)).toBe(true);
+  });
+  test("0 and -0 are equal", () => {
+    expect(isDeepEqual(0, -0)).toBe(true);
+  });
+  test("Infinity and Infinity are equal", () => {
+    expect(
+      isDeepEqual(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY),
+    ).toBe(true);
+  });
+  test("Infinity and -Infinity are not equal", () => {
+    expect(
+      isDeepEqual(Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY),
+    ).toBe(false);
+  });
+});
+
+describe("objects", () => {
+  test("empty objects are equal", () => {
+    expect(isDeepEqual({}, {})).toBe(true);
+  });
+  test('equal objects (same properties "order")', () => {
+    expect(isDeepEqual({ a: 1, b: "2" }, { a: 1, b: "2" })).toBe(true);
+  });
+  test('equal objects (different properties "order")', () => {
+    expect(isDeepEqual({ a: 1, b: "2" }, { b: "2", a: 1 })).toBe(true);
+  });
+  test("not equal objects (extra property)", () => {
+    expect(isDeepEqual({ a: 1, b: "2" }, { a: 1, b: "2", c: [] })).toBe(false);
+  });
+  test("not equal objects (different properties) #1", () => {
+    expect(
+      isDeepEqual({ a: 1, b: "2", c: 3 } as unknown, { a: 1, b: "2", d: 3 }),
+    ).toBe(false);
+  });
+  test("not equal objects (different properties) #2", () => {
+    expect(
+      isDeepEqual({ a: 1, b: "2", c: 3 } as unknown, { a: 1, b: "2", d: 3 }),
+    ).toBe(false);
+  });
+  test("equal objects (same sub-properties)", () => {
+    expect(isDeepEqual({ a: [{ b: "c" }] }, { a: [{ b: "c" }] })).toBe(true);
+  });
+  test("not equal objects (different sub-property value)", () => {
+    expect(isDeepEqual({ a: [{ b: "c" }] }, { a: [{ b: "d" }] })).toBe(false);
+  });
+  test("not equal objects (different sub-property)", () => {
+    expect(
+      isDeepEqual({ a: [{ b: "c" }] } as unknown, { a: [{ c: "c" }] }),
+    ).toBe(false);
+  });
+  test("empty array and empty object are not equal", () => {
+    expect(isDeepEqual({}, [])).toBe(false);
+  });
+  test("object with extra undefined properties are not equal #1", () => {
+    expect(isDeepEqual({}, { foo: undefined })).toBe(false);
+  });
+  test("object with extra undefined properties are not equal #2", () => {
+    expect(isDeepEqual({ foo: undefined } as unknown, {})).toBe(false);
+  });
+  test("object with extra undefined properties are not equal #3", () => {
+    expect(isDeepEqual({ foo: undefined } as unknown, { bar: undefined })).toBe(
+      false,
+    );
+  });
+  test("nulls are equal", () => {
+    expect(isDeepEqual(null, null)).toBe(true);
+  });
+  test("null and undefined are not equal", () => {
+    expect(isDeepEqual(null as unknown, undefined)).toBe(false);
+  });
+  test("null and empty object are not equal", () => {
+    expect(isDeepEqual(null as unknown, {})).toBe(false);
+  });
+  test("undefined and empty object are not equal", () => {
+    expect(isDeepEqual(undefined as unknown, {})).toBe(false);
+  });
+});
+
+describe("arrays", () => {
+  test("two empty arrays are equal", () => {
+    expect(isDeepEqual([], [])).toBe(true);
+  });
+  test("equal arrays", () => {
+    expect(isDeepEqual([1, 2, 3], [1, 2, 3])).toBe(true);
+  });
+  test("not equal arrays (different item)", () => {
+    expect(isDeepEqual([1, 2, 3], [1, 2, 4])).toBe(false);
+  });
+  test("not equal arrays (different length)", () => {
+    expect(isDeepEqual([1, 2, 3], [1, 2])).toBe(false);
+  });
+  test("equal arrays of objects", () => {
+    expect(
+      isDeepEqual([{ a: "a" }, { b: "b" }], [{ a: "a" }, { b: "b" }]),
+    ).toBe(true);
+  });
+  test("not equal arrays of objects", () => {
+    expect(
+      isDeepEqual([{ a: "a" }, { b: "b" }], [{ a: "a" }, { b: "c" }]),
+    ).toBe(false);
+  });
+  test("pseudo array and equivalent array are not equal", () => {
+    expect(isDeepEqual({ "0": 0, "1": 1, length: 2 }, [0, 1])).toBe(false);
+  });
+});
+
+describe("Date objects", () => {
+  test("equal date objects", () => {
+    expect(
+      isDeepEqual(
+        new Date("2017-06-16T21:36:48.362Z"),
+        new Date("2017-06-16T21:36:48.362Z"),
+      ),
+    ).toBe(true);
+  });
+  test("not equal date objects", () => {
+    expect(
+      isDeepEqual(
+        new Date("2017-06-16T21:36:48.362Z"),
+        new Date("2017-01-01T00:00:00.000Z"),
+      ),
+    ).toBe(false);
+  });
+  test("date and string are not equal", () => {
+    expect(
+      isDeepEqual(
+        new Date("2017-06-16T21:36:48.362Z") as unknown,
+        "2017-06-16T21:36:48.362Z",
+      ),
+    ).toBe(false);
+  });
+  test("date and object are not equal", () => {
+    expect(
+      isDeepEqual(new Date("2017-06-16T21:36:48.362Z") as unknown, {}),
+    ).toBe(false);
+  });
+});
+
+describe("RegExp objects", () => {
+  test("equal RegExp objects", () => {
+    expect(isDeepEqual(/foo/u, /foo/u)).toBe(true);
+  });
+  test("not equal RegExp objects (different pattern)", () => {
+    expect(isDeepEqual(/foo/u, /bar/u)).toBe(false);
+  });
+  test("not equal RegExp objects (different flags)", () => {
+    expect(isDeepEqual(/foo/u, /foo/iu)).toBe(false);
+  });
+  test("RegExp and string are not equal", () => {
+    expect(isDeepEqual(/foo/u as unknown, "foo")).toBe(false);
+  });
+  test("RegExp and object are not equal", () => {
+    expect(isDeepEqual(/foo/u as unknown, {})).toBe(false);
+  });
+});
+
+describe("functions", () => {
+  test("same function is equal", () => {
+    expect(isDeepEqual(func1, func1)).toBe(true);
+  });
+  test("different functions are not equal", () => {
+    expect(isDeepEqual(func1, func2)).toBe(false);
+  });
+});
+
+describe("sample objects", () => {
+  test("big object", () => {
+    expect(
+      isDeepEqual(
+        {
+          prop1: "value1",
+          prop2: "value2",
+          prop3: "value3",
+          prop4: {
+            subProp1: "sub value1",
+            subProp2: {
+              subSubProp1: "sub sub value1",
+              subSubProp2: [1, 2, { prop2: 1, prop: 2 }, 4, 5],
+            },
+          },
+          prop5: 1000,
+          prop6: new Date(2016, 2, 10),
+        },
+        {
+          prop5: 1000,
+          prop3: "value3",
+          prop1: "value1",
+          prop2: "value2",
+          prop6: new Date("2016/03/10"),
+          prop4: {
+            subProp2: {
+              subSubProp1: "sub sub value1",
+              subSubProp2: [1, 2, { prop2: 1, prop: 2 }, 4, 5],
+            },
+            subProp1: "sub value1",
+          },
+        },
+      ),
+    ).toBe(true);
+  });
+});
+
+describe("typing", () => {
+  it("narrows unions", () => {
+    const data = 1 as number | string;
+
+    if (isDeepEqual(data, 1)) {
+      expectTypeOf(data).toEqualTypeOf<number>();
+    }
+
+    if (isDeepEqual(data, "hello")) {
+      expectTypeOf(data).toEqualTypeOf<string>();
+    }
+  });
+
+  it("narrows to literal", () => {
+    const data = 1 as number;
+    if (isDeepEqual(data, 1 as const)) {
+      expectTypeOf(data).toEqualTypeOf<1>();
+    }
+  });
+
+  it("doesn't accept non-overlapping types", () => {
+    // @ts-expect-error [ts2345] - Checking against the wrong type should fail
+    isDeepEqual(1 as number, true);
+  });
+
+  it("works deeply", () => {
+    const data = [] as Array<
+      { a: Array<number> | Array<string> } | { b: Array<boolean> }
+    >;
+    if (isDeepEqual(data, [{ a: [1] }])) {
+      expectTypeOf(data).toEqualTypeOf<Array<{ a: Array<number> }>>();
+    }
+  });
+});