Repository: remeda/remeda
Pull request #773 (2024-07-07 13:11:40)

feat(isStrictEqual, isShallowEqual): add more equality tools
A non-deep version of an equality guard for cases where you don't need the deepness but still would appreciate the typing and dataLast invocation.

---

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 `docs/src/content/mapping` for both Lodash and Ramda.

---

<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/773/builds/520540) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 48a3024642e38d1060bb2e80771ec925d1b48d7e:

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

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/773?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`32d187d`)](https://app.codecov.io/gh/remeda/remeda/commit/32d187d4d01b93b1e2f45feb0445fd97869aaaff?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) to head [(`48a3024`)](https://app.codecov.io/gh/remeda/remeda/commit/48a3024642e38d1060bb2e80771ec925d1b48d7e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #773   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          148       149    +1     
  Lines        10430     10500   +70     
  Branches       734       737    +3     
=========================================
+ Hits         10430     10500   +70     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/773?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

cjquines: will read closer later, but my first thought is that the PR's current `isEqual` is what i think of when i read `isShallowEqual`, and i don't see a use for the PR's current `isShallowEqual`?
cjquines: in particular, the type-level information i am getting from `isShallowEqual(a, b)` when `!isEqual(a, b)` is that `a` and `b` have the same shape, which is not what i think of when i read `isShallowEqual`. i'm not sure why we'd have an equals function that only goes "one-level deep". it's weird to me that `isShallowEqual(data, { a: 1 })` is sometimes true but `isShallowEqual(data, { a: [1] })` is never true
eranhirsch: This was a suggestion from @TkDodo; the implementation of `isShallowEqual` comes from Zustand's `shallow` function. It's useful when you want to replicate behavior similar to React's dependency arrays.

Patch:
diff --git a/src/index.ts b/src/index.ts
index daf2993..2bb39ee 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -61,6 +61,8 @@ export * from "./isNumber";
 export * from "./isObjectType";
 export * from "./isPlainObject";
 export * from "./isPromise";
+export * from "./isShallowEqual";
+export * from "./isStrictEqual";
 export * from "./isString";
 export * from "./isSymbol";
 export * from "./isTruthy";
diff --git a/src/isDeepEqual.ts b/src/isDeepEqual.ts
index 44fe00a..04f707e 100644
--- a/src/isDeepEqual.ts
+++ b/src/isDeepEqual.ts
@@ -1,11 +1,13 @@
 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.
+ * Performs a *deep structural* 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: TypedArrays and symbol properties of objects are not supported
  * right now and might result in unexpected behavior. Please open an issue in
@@ -14,6 +16,12 @@ import { purry } from "./purry";
  * The result would be narrowed to the second value so that the function can be
  * used as a type guard.
  *
+ * See:
+ * - `isStrictEqual` if you don't need a deep comparison and just want to
+ * check for simple (`===`, `Object.is`) equality.
+ * - `isShallowEqual` if you need to compare arrays and objects "by-value" but
+ * don't want to recurse into their values.
+ *
  * @param data - The first value to compare.
  * @param other - The second value to compare.
  * @signature
@@ -32,11 +40,13 @@ export function isDeepEqual<T, S extends T>(
 export function isDeepEqual<T, S extends T = T>(data: T, other: S): boolean;
 
 /**
- * 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.
+ * Performs a *deep structural* 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: TypedArrays and symbol properties of objects are not supported
  * right now and might result in unexpected behavior. Please open an issue in
@@ -45,6 +55,12 @@ export function isDeepEqual<T, S extends T = T>(data: T, other: S): boolean;
  * The result would be narrowed to the second value so that the function can be
  * used as a type guard.
  *
+ * See:
+ * - `isStrictEqual` if you don't need a deep comparison and just want to
+ * check for simple (`===`, `Object.is`) equality.
+ * - `isShallowEqual` if you need to compare arrays and objects "by-value" but
+ * don't want to recurse into their values.
+ *
  * @param other - The second value to compare.
  * @signature
  *    R.isDeepEqual(other)(data)
diff --git a/src/isShallowEqual.ts b/src/isShallowEqual.ts
new file mode 100644
index 0000000..91a4f40
--- /dev/null
+++ b/src/isShallowEqual.ts
@@ -0,0 +1,167 @@
+import { purry } from "./purry";
+
+/**
+ * Performs a *shallow structural* comparison between two values to determine if
+ * they are equivalent. For primitive values this is equivalent to `===`, for
+ * arrays a **strict equality** check would be performed on every item, in
+ * order, and for objects props will be matched and checked for **strict
+ * equality**; Unlike `isDeepEqual` where the function also *recurses* into each
+ * item and value.
+ *
+ * !IMPORTANT: 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.
+ *
+ * !IMPORTANT: Promise, Date, and RegExp, are shallowly equal, even when they
+ * are semantically different (e.g. resolved promises); but `isDeepEqual` does
+ * compare the latter 2 semantically by-value.
+ *
+ * The result would be narrowed to the second value so that the function can be
+ * used as a type guard.
+ *
+ * See:
+ * - `isStrictEqual` if you don't need a deep comparison and just want to check
+ * for simple (`===`, `Object.is`) equality.
+ * - `isDeepEqual` for a recursively deep check of arrays and objects.
+ *
+ * @param data - The first value to compare.
+ * @param other - The second value to compare.
+ * @signature
+ *    R.isShallowEqual(data, other)
+ * @example
+ *    R.isShallowEqual(1, 1) //=> true
+ *    R.isShallowEqual(1, '1') //=> false
+ *    R.isShallowEqual([1, 2, 3], [1, 2, 3]) //=> true
+ *    R.isShallowEqual([[1], [2], [3]], [[1], [2], [3]]) //=> false
+ * @dataFirst
+ * @category Guard
+ */
+export function isShallowEqual<T, S extends T>(
+  data: T,
+  other: T extends Exclude<T, S> ? S : never,
+): data is S;
+export function isShallowEqual<T, S extends T = T>(data: T, other: S): boolean;
+
+/**
+ * Performs a *shallow structural* comparison between two values to determine if
+ * they are equivalent. For primitive values this is equivalent to `===`, for
+ * arrays a **strict equality** check would be performed on every item, in
+ * order, and for objects props will be matched and checked for **strict
+ * equality**; Unlike `isDeepEqual` where the function also *recurses* into each
+ * item and value.
+ *
+ * !IMPORTANT: 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.
+ *
+ * !IMPORTANT: All built-in objects (Promise, Date, RegExp) are shallowly equal,
+ * even when they are semantically different (e.g. resolved promises). Use
+ * `isDeepEqual` instead.
+ *
+ * The result would be narrowed to the second value so that the function can be
+ * used as a type guard.
+ *
+ * See:
+ * - `isStrictEqual` if you don't need a deep comparison and just want to check
+ * for simple (`===`, `Object.is`) equality.
+ * - `isDeepEqual` for a recursively deep check of arrays and objects.
+ *
+ * @param other - The second value to compare.
+ * @signature
+ *    R.isShallowEqual(other)(data)
+ * @example
+ *    R.pipe(1, R.isShallowEqual(1)) //=> true
+ *    R.pipe(1, R.isShallowEqual('1')) //=> false
+ *    R.pipe([1, 2, 3], R.isShallowEqual([1, 2, 3])) //=> true
+ *    R.pipe([[1], [2], [3]], R.isShallowEqual([[1], [2], [3]])) //=> false
+ * @dataFirst
+ * @category Guard
+ */
+export function isShallowEqual<T, S extends T>(
+  other: T extends Exclude<T, S> ? S : never,
+): (data: T) => data is S;
+export function isShallowEqual<S>(
+  other: S,
+): <T extends S = S>(data: T) => boolean;
+
+export function isShallowEqual(...args: ReadonlyArray<unknown>): unknown {
+  return purry(isShallowEqualImplementation, args);
+}
+
+function isShallowEqualImplementation<T>(a: T, b: T): boolean {
+  if (a === b || Object.is(a, b)) {
+    return true;
+  }
+
+  if (
+    typeof a !== "object" ||
+    a === null ||
+    typeof b !== "object" ||
+    b === null
+  ) {
+    return false;
+  }
+
+  if (a instanceof Map && b instanceof Map) {
+    return isMapShallowEqual(a, b);
+  }
+
+  if (a instanceof Set && b instanceof Set) {
+    return isSetShallowEqual(a, b);
+  }
+
+  const keys = Object.keys(a);
+  if (keys.length !== Object.keys(b).length) {
+    return false;
+  }
+
+  for (const key of keys) {
+    if (!Object.prototype.hasOwnProperty.call(b, key)) {
+      return false;
+    }
+
+    const { [key as keyof T]: valueA } = a;
+    const { [key as keyof T]: valueB } = b;
+
+    if (valueA !== valueB || !Object.is(valueA, valueB)) {
+      return false;
+    }
+  }
+
+  return true;
+}
+
+function isMapShallowEqual(
+  a: ReadonlyMap<unknown, unknown>,
+  b: ReadonlyMap<unknown, unknown>,
+): boolean {
+  if (a.size !== b.size) {
+    return false;
+  }
+
+  for (const [key, value] of a) {
+    const valueB = b.get(key);
+    if (value !== valueB || !Object.is(value, valueB)) {
+      return false;
+    }
+  }
+
+  return true;
+}
+
+function isSetShallowEqual(
+  a: ReadonlySet<unknown>,
+  b: ReadonlySet<unknown>,
+): boolean {
+  if (a.size !== b.size) {
+    return false;
+  }
+
+  for (const value of a) {
+    if (!b.has(value)) {
+      return false;
+    }
+  }
+
+  return true;
+}
diff --git a/src/isStrictEqual.ts b/src/isStrictEqual.ts
new file mode 100644
index 0000000..110d29d
--- /dev/null
+++ b/src/isStrictEqual.ts
@@ -0,0 +1,82 @@
+import { purry } from "./purry";
+
+/**
+ * Determines whether two values are *functionally identical* in all contexts.
+ * For primitive values (string, number), this is done by-value, and for objects
+ * it is done by-reference (i.e., they point to the same object in memory).
+ *
+ * Under the hood we use **both** the [`===` operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality)
+ * and [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is). This means that `isEqual(NaN, NaN) === true` (whereas
+ * `NaN !== NaN`), and `isEqual(-0, 0) === true` (whereas
+ * `Object.is(-0, 0) === false`).
+ *
+ * The result would be narrowed to the second value so that the function can be
+ * used as a type guard.
+ *
+ * See:
+ * - `isDeepEqual` for a semantic comparison that allows comparing arrays and
+ * objects "by-value", and recurses for every item.
+ * - `isShallowEqual` if you need to compare arrays and objects "by-value" but
+ * don't want to recurse into their values.
+ *
+ * @param data - The first value to compare.
+ * @param other - The second value to compare.
+ * @signature
+ *    R.isEqual(data, other)
+ * @example
+ *    R.isEqual(1, 1) //=> true
+ *    R.isEqual(1, '1') //=> false
+ *    R.isEqual([1, 2, 3], [1, 2, 3]) //=> false
+ * @dataFirst
+ * @category Guard
+ */
+export function isStrictEqual<T, S extends T>(
+  data: T,
+  other: T extends Exclude<T, S> ? S : never,
+): data is S;
+export function isStrictEqual<T, S extends T = T>(data: T, other: S): boolean;
+
+/**
+ * Determines whether two values are *functionally identical* in all contexts.
+ * For primitive values (string, number), this is done by-value, and for objects
+ * it is done by-reference (i.e., they point to the same object in memory).
+ *
+ * Under the hood we use **both** the [`===` operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality)
+ * and [`Object.is`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is). This means that `isEqual(NaN, NaN) === true` (whereas
+ * `NaN !== NaN`), and `isEqual(-0, 0) === true` (whereas
+ * `Object.is(-0, 0) === false`).
+ *
+ * The result would be narrowed to the second value so that the function can be
+ * used as a type guard.
+ *
+ * See:
+ * - `isDeepEqual` for a semantic comparison that allows comparing arrays and
+ * objects "by-value", and recurses for every item.
+ * - `isShallowEqual` if you need to compare arrays and objects "by-value" but
+ * don't want to recurse into their values.
+ *
+ * @param other - The second value to compare.
+ * @signature
+ *    R.isEqual(other)(data)
+ * @example
+ *    R.pipe(1, R.isEqual(1)); //=> true
+ *    R.pipe(1, R.isEqual('1')); //=> false
+ *    R.pipe([1, 2, 3], R.isEqual([1, 2, 3])); //=> false
+ * @dataLast
+ * @category Guard
+ */
+export function isStrictEqual<T, S extends T>(
+  other: T extends Exclude<T, S> ? S : never,
+): (data: T) => data is S;
+export function isStrictEqual<S>(
+  other: S,
+): <T extends S = S>(data: T) => boolean;
+
+export function isStrictEqual(...args: ReadonlyArray<unknown>): unknown {
+  return purry(isStrictlyEqualImplementation, args);
+}
+
+const isStrictlyEqualImplementation = <T, S>(
+  data: S | T,
+  other: S,
+): data is S => data === other || Object.is(data, other);


Test patch:
diff --git a/src/isShallowEqual.test.ts b/src/isShallowEqual.test.ts
new file mode 100644
index 0000000..f5b68b6
--- /dev/null
+++ b/src/isShallowEqual.test.ts
@@ -0,0 +1,211 @@
+import { differenceWith } from "./differenceWith";
+import { isShallowEqual } from "./isShallowEqual";
+
+describe("runtime", () => {
+  describe("primitives", () => {
+    test("undefined", () => {
+      expect(isShallowEqual(undefined, undefined)).toBe(true);
+    });
+
+    test("null", () => {
+      expect(isShallowEqual(null, null)).toBe(true);
+    });
+
+    test("string", () => {
+      expect(isShallowEqual("a", "a")).toBe(true);
+      expect(isShallowEqual("a", "b")).toBe(false);
+    });
+
+    test("number", () => {
+      expect(isShallowEqual(1, 1)).toBe(true);
+      expect(isShallowEqual(1, 2)).toBe(false);
+    });
+
+    test("boolean", () => {
+      expect(isShallowEqual(true, true)).toBe(true);
+      expect(isShallowEqual(true, false)).toBe(false);
+    });
+
+    test("bigint", () => {
+      expect(isShallowEqual(1n, 1n)).toBe(true);
+      expect(isShallowEqual(1n, 2n)).toBe(false);
+    });
+  });
+
+  describe("objects", () => {
+    test("arrays", () => {
+      const data = [1, 2, 3];
+      expect(isShallowEqual(data, [1, 2, 3])).toBe(true);
+      expect(isShallowEqual(data, data)).toBe(true);
+
+      expect(isShallowEqual(data, [1, 2])).toBe(false);
+    });
+
+    test("objects", () => {
+      const data = { a: 1, b: 2 } as Record<string, number>;
+      expect(isShallowEqual(data, { a: 1, b: 2 })).toBe(true);
+      expect(isShallowEqual(data, data)).toBe(true);
+
+      expect(isShallowEqual(data, { a: 1 })).toBe(false);
+      expect(isShallowEqual(data, { a: 1, c: 3 })).toBe(false);
+    });
+
+    test("uint arrays", () => {
+      const data = new Uint8Array([1, 2, 3]);
+      expect(isShallowEqual(data, new Uint8Array([1, 2, 3]))).toBe(true);
+      expect(isShallowEqual(data, data)).toBe(true);
+
+      expect(isShallowEqual(data, new Uint8Array([1, 2]))).toBe(false);
+    });
+
+    test("maps", () => {
+      const data = new Map([["a", 1]]);
+      expect(isShallowEqual(data, new Map([["a", 1]]))).toBe(true);
+      expect(isShallowEqual(data, data)).toBe(true);
+
+      expect(isShallowEqual(data, new Map([["a", 2]]))).toBe(false);
+      expect(
+        isShallowEqual(
+          data,
+          new Map([
+            ["a", 1],
+            ["b", 2],
+          ]),
+        ),
+      ).toBe(false);
+    });
+
+    test("sets", () => {
+      const data = new Set([1, 2, 3]);
+      expect(isShallowEqual(data, new Set([1, 2, 3]))).toBe(true);
+      expect(isShallowEqual(data, data)).toBe(true);
+
+      expect(isShallowEqual(data, new Set([1, 2]))).toBe(false);
+      expect(isShallowEqual(data, new Set([4, 5, 6]))).toBe(false);
+    });
+  });
+
+  describe("built-ins", () => {
+    test("regex", () => {
+      const data = /a/u;
+      expect(isShallowEqual(data, /a/u)).toBe(true);
+      expect(isShallowEqual(data, data)).toBe(true);
+
+      expect(isShallowEqual(data, /b/u)).toBe(true);
+    });
+
+    test("dates", () => {
+      const data = new Date();
+      expect(isShallowEqual(data, new Date())).toBe(true);
+      expect(isShallowEqual(data, data)).toBe(true);
+
+      expect(isShallowEqual(data, new Date(data.getTime() + 1))).toBe(true);
+    });
+
+    test("promises", () => {
+      const data = Promise.resolve(1);
+      expect(isShallowEqual(data, Promise.resolve(1))).toBe(true);
+      expect(isShallowEqual(data, data)).toBe(true);
+
+      expect(isShallowEqual(data, Promise.resolve(2))).toBe(true);
+    });
+  });
+
+  describe("shallow inequality", () => {
+    test("arrays of objects", () => {
+      const a = { a: 1 };
+      expect(isShallowEqual([a], [a])).toBe(true);
+      expect(isShallowEqual([a], [{ a: 1 }])).toBe(false);
+    });
+
+    test("arrays of arrays", () => {
+      const a = [1];
+      expect(isShallowEqual([a], [a])).toBe(true);
+      expect(isShallowEqual([a], [[1]])).toBe(false);
+    });
+
+    test("objects of arrays", () => {
+      const a = [1];
+      expect(isShallowEqual({ a }, { a })).toBe(true);
+      expect(isShallowEqual({ a }, { a: [1] })).toBe(false);
+    });
+
+    test("objects of objects", () => {
+      const a = { b: 1 };
+      expect(isShallowEqual({ a }, { a })).toBe(true);
+      expect(isShallowEqual({ a }, { a: { b: 1 } })).toBe(false);
+    });
+  });
+});
+
+describe("typing", () => {
+  it("narrows unions", () => {
+    const data = 1 as number | string;
+
+    if (isShallowEqual(data, 1)) {
+      expectTypeOf(data).toEqualTypeOf<number>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<number | string>();
+    }
+
+    if (isShallowEqual(data, "hello")) {
+      expectTypeOf(data).toEqualTypeOf<string>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<number | string>();
+    }
+  });
+
+  it("narrows to literal", () => {
+    const data = 1 as number;
+    if (isShallowEqual(data, 1 as const)) {
+      expectTypeOf(data).toEqualTypeOf<1>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<number>();
+    }
+  });
+
+  it("doesn't accept non-overlapping types", () => {
+    // @ts-expect-error [ts2345] - Checking against the wrong type should fail
+    isShallowEqual(1 as number, true);
+  });
+
+  it("works deeply", () => {
+    const data = [] as Array<
+      { a: Array<number> | Array<string> } | { b: Array<boolean> }
+    >;
+    if (isShallowEqual(data, [{ a: [1] }])) {
+      expectTypeOf(data).toEqualTypeOf<Array<{ a: Array<number> }>>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<
+        Array<
+          | {
+              a: Array<number> | Array<string>;
+            }
+          | {
+              b: Array<boolean>;
+            }
+        >
+      >();
+    }
+  });
+
+  it("doesn't narrow when comparing objects of the same type", () => {
+    const data1 = { a: 1 } as { a: number };
+    const data2 = { a: 2 } as { a: number };
+    if (isShallowEqual(data1, data2)) {
+      expectTypeOf(data1).toEqualTypeOf<{ a: number }>();
+    } else {
+      expectTypeOf(data1).toEqualTypeOf<{ a: number }>();
+    }
+  });
+
+  it("headless usage can infer types", () => {
+    // Tests the issue reported in: https://github.com/remeda/remeda/issues/641
+    const result = differenceWith(
+      ["a", "b", "c"],
+      ["a", "c", "d"],
+      isShallowEqual,
+    );
+    expectTypeOf(result).toEqualTypeOf<Array<string>>();
+  });
+});
diff --git a/src/isStrictEqual.test.ts b/src/isStrictEqual.test.ts
new file mode 100644
index 0000000..bfa5187
--- /dev/null
+++ b/src/isStrictEqual.test.ts
@@ -0,0 +1,184 @@
+import { differenceWith } from "./differenceWith";
+import { isStrictEqual } from "./isStrictEqual";
+
+describe("runtime", () => {
+  describe("primitives", () => {
+    test("undefined", () => {
+      expect(isStrictEqual(undefined, undefined)).toBe(true);
+    });
+
+    test("null", () => {
+      expect(isStrictEqual(null, null)).toBe(true);
+    });
+
+    test("string", () => {
+      expect(isStrictEqual("a", "a")).toBe(true);
+      expect(isStrictEqual("a", "b")).toBe(false);
+    });
+
+    test("number", () => {
+      expect(isStrictEqual(1, 1)).toBe(true);
+      expect(isStrictEqual(1, 2)).toBe(false);
+    });
+
+    test("boolean", () => {
+      expect(isStrictEqual(true, true)).toBe(true);
+      expect(isStrictEqual(true, false)).toBe(false);
+    });
+
+    test("bigint", () => {
+      expect(isStrictEqual(1n, 1n)).toBe(true);
+      expect(isStrictEqual(1n, 2n)).toBe(false);
+    });
+  });
+
+  describe("objects", () => {
+    test("arrays", () => {
+      const data = [1, 2, 3];
+      expect(isStrictEqual(data, [1, 2, 3])).toBe(false);
+      expect(isStrictEqual(data, data)).toBe(true);
+    });
+
+    test("objects", () => {
+      const data = { a: 1, b: 2 };
+      expect(isStrictEqual(data, { a: 1, b: 2 })).toBe(false);
+      expect(isStrictEqual(data, data)).toBe(true);
+    });
+
+    test("uint arrays", () => {
+      const data = new Uint8Array([1, 2, 3]);
+      expect(isStrictEqual(data, new Uint8Array([1, 2, 3]))).toBe(false);
+      expect(isStrictEqual(data, data)).toBe(true);
+    });
+
+    test("maps", () => {
+      const data = new Map([["a", 1]]);
+      expect(isStrictEqual(data, new Map([["a", 1]]))).toBe(false);
+      expect(isStrictEqual(data, data)).toBe(true);
+    });
+
+    test("sets", () => {
+      const data = new Set([1, 2, 3]);
+      expect(isStrictEqual(data, new Set([1, 2, 3]))).toBe(false);
+      expect(isStrictEqual(data, data)).toBe(true);
+    });
+  });
+
+  describe("built-ins", () => {
+    test("regex", () => {
+      const data = /a/u;
+      expect(isStrictEqual(data, /a/u)).toBe(false);
+      expect(isStrictEqual(data, data)).toBe(true);
+    });
+
+    test("dates", () => {
+      const data = new Date();
+      expect(isStrictEqual(data, new Date())).toBe(false);
+      expect(isStrictEqual(data, data)).toBe(true);
+    });
+
+    test("promises", () => {
+      const data = Promise.resolve(1);
+      expect(isStrictEqual(data, Promise.resolve(1))).toBe(false);
+      expect(isStrictEqual(data, data)).toBe(true);
+    });
+  });
+
+  describe("special cases", () => {
+    test("NaN", () => {
+      // eslint-disable-next-line unicorn/prefer-number-properties
+      expect(isStrictEqual(NaN, NaN)).toBe(true);
+      expect(isStrictEqual(Number.NaN, Number.NaN)).toBe(true);
+    });
+
+    test("-0", () => {
+      expect(isStrictEqual(-0, 0)).toBe(true);
+      expect(isStrictEqual(-0, -0)).toBe(true);
+      expect(isStrictEqual(0, 0)).toBe(true);
+    });
+
+    it("fails on loose equality", () => {
+      expect(isStrictEqual("" as unknown, 0)).toBe(false);
+      expect(isStrictEqual("" as unknown, false)).toBe(false);
+      expect(isStrictEqual(0 as unknown, false)).toBe(false);
+      expect(isStrictEqual("" as unknown, null)).toBe(false);
+      expect(isStrictEqual("" as unknown, undefined)).toBe(false);
+      expect(isStrictEqual(0 as unknown, null)).toBe(false);
+      expect(isStrictEqual(0 as unknown, undefined)).toBe(false);
+      expect(isStrictEqual(false as unknown, null)).toBe(false);
+      expect(isStrictEqual(false as unknown, undefined)).toBe(false);
+    });
+  });
+});
+
+describe("typing", () => {
+  it("narrows unions", () => {
+    const data = 1 as number | string;
+
+    if (isStrictEqual(data, 1)) {
+      expectTypeOf(data).toEqualTypeOf<number>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<number | string>();
+    }
+
+    if (isStrictEqual(data, "hello")) {
+      expectTypeOf(data).toEqualTypeOf<string>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<number | string>();
+    }
+  });
+
+  it("narrows to literal", () => {
+    const data = 1 as number;
+    if (isStrictEqual(data, 1 as const)) {
+      expectTypeOf(data).toEqualTypeOf<1>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<number>();
+    }
+  });
+
+  it("doesn't accept non-overlapping types", () => {
+    // @ts-expect-error [ts2345] - Checking against the wrong type should fail
+    isStrictEqual(1 as number, true);
+  });
+
+  it("works deeply", () => {
+    const data = [] as Array<
+      { a: Array<number> | Array<string> } | { b: Array<boolean> }
+    >;
+    if (isStrictEqual(data, [{ a: [1] }])) {
+      expectTypeOf(data).toEqualTypeOf<Array<{ a: Array<number> }>>();
+    } else {
+      expectTypeOf(data).toEqualTypeOf<
+        Array<
+          | {
+              a: Array<number> | Array<string>;
+            }
+          | {
+              b: Array<boolean>;
+            }
+        >
+      >();
+    }
+  });
+
+  it("doesn't narrow when comparing objects of the same type", () => {
+    const data1 = { a: 1 } as { a: number };
+    const data2 = { a: 2 } as { a: number };
+    if (isStrictEqual(data1, data2)) {
+      expectTypeOf(data1).toEqualTypeOf<{ a: number }>();
+    } else {
+      expectTypeOf(data1).toEqualTypeOf<{ a: number }>();
+    }
+  });
+
+  it("headless usage can infer types", () => {
+    // Tests the issue reported in: https://github.com/remeda/remeda/issues/641
+    const result = differenceWith(
+      ["a", "b", "c"],
+      ["a", "c", "d"],
+      isStrictEqual,
+    );
+    expectTypeOf(result).toEqualTypeOf<Array<string>>();
+  });
+});