Repository: remeda/remeda
Pull request #636 (2024-04-13 08:40:36)

feat(difference, intersection): add multiset variants
Adding a variant difference for multiset semantics and update the deprecation warning.


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

Latest deployment of this branch, based on commit c8c5e2812a0de3ca6126476978a20bb983445ae8:

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


Patch:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index a3618fc..c0f1962 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -362,6 +362,7 @@ export default config(
                 "IArguments",
                 "Iterable",
                 "Promise",
+                "ReadonlyMap",
                 "RegExp",
               ],
             },
diff --git a/mapping.md b/mapping.md
index a4e8694..800f6eb 100644
--- a/mapping.md
+++ b/mapping.md
@@ -20,6 +20,7 @@ documentation when migrating._
 | [`conditional`](https://remedajs.com/docs/#conditional)             | [`cond`](https://lodash.com/docs/4.17.15#cond)                           | [`cond`](https://ramdajs.com/docs/#cond)                           |
 | [`constant`](https://remedajs.com/docs/#constant)                   | [`constant`](https://lodash.com/docs/4.17.15#constant)                   | [`always`](https://ramdajs.com/docs/#always)                       |
 | [`debounce`](https://remedajs.com/docs/#debounce)                   | [`debounce`](https://lodash.com/docs/4.17.15#debounce)                   |                                                                    |
+| [`difference`](https://remedajs.com/docs/#difference)               | [`difference`](https://lodash.com/docs/4.17.15#difference)               | [`difference`](https://ramdajs.com/docs/#difference)               |
 | [`differenceWith`](https://remedajs.com/docs/#differenceWith)       | [`differenceWith`](https://lodash.com/docs/4.17.15#differenceWith)       | [`differenceWith`](https://ramdajs.com/docs/#differenceWith)       |
 | [`divide`](https://remedajs.com/docs/#divide)                       | [`divide`](https://lodash.com/docs/4.17.15#divide)                       | [`divide`](https://ramdajs.com/docs/#divide)                       |
 | [`drop`](https://remedajs.com/docs/#drop)                           | [`drop`](https://lodash.com/docs/4.17.15#drop)                           | [`drop`](https://ramdajs.com/docs/#drop)                           |
@@ -34,9 +35,9 @@ documentation when migrating._
 | [`findLast`](https://remedajs.com/docs/#findLast)                   | [`findLast`](https://lodash.com/docs/4.17.15#findLast)                   | [`findLast`](https://ramdajs.com/docs/#findLast)                   |
 | [`findLastIndex`](https://remedajs.com/docs/#findLastIndex)         | [`findLastIndex`](https://lodash.com/docs/4.17.15#findLastIndex)         | [`findLastIndex`](https://ramdajs.com/docs/#findLastIndex)         |
 | [`first`](https://remedajs.com/docs/#first)                         | [`head`](https://lodash.com/docs/4.17.15#head)                           | [`head`](https://ramdajs.com/docs/#head)                           |
-| [`flatMap`](https://remedajs.com/docs/#flatMap)                     | [`flatMap`](https://lodash.com/docs/4.17.15#flatMap)                     | [`chain`](https://ramdajs.com/docs/#chain)                         |
 | [`flat`](https://remedajs.com/docs/#flat)                           | [`flatten`](https://lodash.com/docs/4.17.15#flatten)                     | [`flatten`](https://ramdajs.com/docs/#flatten)                     |
 | [`flat`](https://remedajs.com/docs/#flattenDeep)                    | [`flattenDeep`](https://lodash.com/docs/4.17.15#flattenDeep)             |                                                                    |
+| [`flatMap`](https://remedajs.com/docs/#flatMap)                     | [`flatMap`](https://lodash.com/docs/4.17.15#flatMap)                     | [`chain`](https://ramdajs.com/docs/#chain)                         |
 | [`floor`](https://remedajs.com/docs/#floor)                         | [`floor`](https://lodash.com/docs/4.17.15#floor)                         |                                                                    |
 | [`forEach`](https://remedajs.com/docs/#forEach)                     | [`forEach`](https://lodash.com/docs/4.17.15#forEach)                     | [`forEach`](https://ramdajs.com/docs/#forEach)                     |
 | [`forEachObj`](https://remedajs.com/docs/#forEachObj)               | [`forEach`](https://lodash.com/docs/4.17.15#forEach)                     | [`forEachObjIndexed`](https://ramdajs.com/docs/#forEachObjIndexed) |
@@ -44,6 +45,7 @@ documentation when migrating._
 | [`hasSubobject`](https://remedajs.com/docs/#hasSubobject)           | [`isMatch`](https://lodash.com/docs/4.17.15#isMatch)                     | [`where`](https://ramdajs.com/docs/#where)                         |
 | [`identity`](https://remedajs.com/docs/#identity)                   | [`identity`](https://lodash.com/docs/4.17.15#identity)                   | [`identity`](https://ramdajs.com/docs/#identity)                   |
 | [`indexBy`](https://remedajs.com/docs/#indexBy)                     | [`keyBy`](https://lodash.com/docs/4.17.15#keyBy)                         | [`indexBy`](https://ramdajs.com/docs/#indexBy)                     |
+| [`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)                       |
@@ -121,22 +123,20 @@ provide the exact functionality of the listed Lodash and Ramda functions.
 Be sure to consult each library's documentation and to check what features
 you are relying on when migrating._
 
-| Remeda                                                                                 | Lodash         | Ramda          |
-| -------------------------------------------------------------------------------------- | -------------- | -------------- |
-| `(arr, val) => [...arr, val]`                                                          |                | `append`       |
-| `a?.b?.c`                                                                              | `path`         |                |
-| `n => a[n]`                                                                            | `nth`          | `nth`          |
-| `R.constant(undefined)`                                                                | `noop`         |                |
-| `R.filter.indexed((item, index, array) => index === 0 \|\| item !== array[index - 1])` | `sortedUniq`   |                |
-| `R.filter(R.isIncludedIn(other))`                                                      | `intersection` | `intersection` |
-| `R.filter(R.isNot(fn))`                                                                | `reject`       | `reject`       |
-| `R.filter(R.isNot(R.isIncludedIn(other)))`                                             | `difference`   | `difference`   |
-| `R.filter(R.isTruthy)`                                                                 | `compact`      |                |
-| `R.firstBy([fn, "desc"])`                                                              | `maxBy`        | `maxBy`        |
-| `R.firstBy([R.identity, "desc"])`                                                      | `max`          | `max`          |
-| `R.firstBy(fn)`                                                                        | `minBy`        | `minBy`        |
-| `R.firstBy(R.identity)`                                                                | `min`          | `min`          |
-| `R.fromEntries.strict(R.zip(keys, values))`                                            | `zipObj`       | `zipObj`       |
-| `str => str.split(/\s+/)`                                                              | `words`        |                |
-| `x => x.a ?? defaultValue`                                                             | `propOr`       |                |
-| `x => x.a === value`                                                                   | `propEq`       |                |
+| Remeda                                                                                 | Lodash       | Ramda    |
+| -------------------------------------------------------------------------------------- | ------------ | -------- |
+| `(arr, val) => [...arr, val]`                                                          |              | `append` |
+| `a?.b?.c`                                                                              | `path`       |          |
+| `n => a[n]`                                                                            | `nth`        | `nth`    |
+| `R.constant(undefined)`                                                                | `noop`       |          |
+| `R.filter.indexed((item, index, array) => index === 0 \|\| item !== array[index - 1])` | `sortedUniq` |          |
+| `R.filter(R.isNot(fn))`                                                                | `reject`     | `reject` |
+| `R.filter(R.isTruthy)`                                                                 | `compact`    |          |
+| `R.firstBy([fn, "desc"])`                                                              | `maxBy`      | `maxBy`  |
+| `R.firstBy([R.identity, "desc"])`                                                      | `max`        | `max`    |
+| `R.firstBy(fn)`                                                                        | `minBy`      | `minBy`  |
+| `R.firstBy(R.identity)`                                                                | `min`        | `min`    |
+| `R.fromEntries.strict(R.zip(keys, values))`                                            | `zipObj`     | `zipObj` |
+| `str => str.split(/\s+/)`                                                              | `words`      |          |
+| `x => x.a ?? defaultValue`                                                             | `propOr`     |          |
+| `x => x.a === value`                                                                   | `propEq`     |          |
diff --git a/src/_utilityEvaluators.ts b/src/_utilityEvaluators.ts
new file mode 100644
index 0000000..8c6e7cb
--- /dev/null
+++ b/src/_utilityEvaluators.ts
@@ -0,0 +1,20 @@
+import type { LazyResult } from "./pipe";
+
+const EMPTY_PIPE = { done: true, hasNext: false } as const;
+
+/**
+ * A helper evaluator when we want to return an empty result. It memoizes both
+ * the result and the evaluator itself to reduce memory usage.
+ */
+export const lazyEmptyEvaluator = <T>(): LazyResult<T> => EMPTY_PIPE;
+
+/**
+ * A helper evaluator when we want to return a shallow clone of the input. It
+ * memoizes both the evaluator itself to reduce memory usage.
+ */
+export const lazyIdentityEvaluator = <T>(value: T) =>
+  ({
+    hasNext: true,
+    next: value,
+    done: false,
+  }) as const;
diff --git a/src/difference.ts b/src/difference.ts
index 470ea3e..3d34f17 100644
--- a/src/difference.ts
+++ b/src/difference.ts
@@ -1,47 +1,59 @@
 import { _reduceLazy } from "./_reduceLazy";
+import { lazyIdentityEvaluator } from "./_utilityEvaluators";
 import type { LazyEvaluator } from "./pipe";
 import { purry } from "./purry";
 
 /**
- * Excludes the values from `other` array.
+ * Excludes the values from `other` array. The output maintains the same order
+ * as the input. If either `array` or `other` contain multiple items with the
+ * same values, all occurrences of those values will be removed. If the exact
+ * number of copies should be observed (i.e. multi-set semantics), use
+ * `R.difference.multiset` instead. If the arrays don't contain duplicates, both
+ * implementations yield the same result.
  *
- * ! **DEPRECATED**: Use `R.filter(array, R.isNot(R.isIncludedIn(other)))`. Will be removed in v2!
+ * ! **DEPRECATED**: Use `R.difference.multiset(data, other)` (or `R.filter(data, R.isNot(R.isIncludedIn(other)))` to keep the current runtime logic). `R.difference.multiset` will replace `R.difference` in v2!
  *
- * @param array - The source array.
+ * @param data - The input items.
  * @param other - The values to exclude.
  * @signature
- *    R.difference(array, other)
+ *    R.difference(data, other)
+ *    R.difference.multiset(data, other)
  * @example
- *    R.difference([1, 2, 3, 4], [2, 5, 3]) // => [1, 4]
+ *    R.difference([1, 2, 3, 4], [2, 5, 3]); // => [1, 4]
+ *    R.difference([1, 1, 2, 2], [1]); // => [2, 2]
+ *    R.difference.multiset([1, 1, 2, 2], [1]); // => [1, 2, 2]
  * @dataFirst
  * @pipeable
- * @category Deprecated
- * @deprecated Use `R.filter(array, R.isNot(R.isIncludedIn(other)))`. Will be removed in v2.
+ * @category Array
+ * @deprecated Use `R.difference.multiset(data, other)` (or `R.filter(data, R.isNot(R.isIncludedIn(other)))` to keep the current runtime logic). `R.difference.multiset` will replace `R.difference` in v2!
  */
 export function difference<T>(
-  array: ReadonlyArray<T>,
+  data: ReadonlyArray<T>,
   other: ReadonlyArray<T>,
 ): Array<T>;
 
 /**
- * Excludes the values from `other` array.
+ * Excludes the values from `other` array. The output maintains the same order
+ * as the input. If either `array` or `other` contain multiple items with the
+ * same values, all occurrences of those values will be removed. If the exact
+ * number of copies should be observed (i.e. multi-set semantics), use
+ * `R.difference.multiset` instead. If the arrays don't contain duplicates, both
+ * implementations yield the same result.
  *
- * ! **DEPRECATED**: Use `R.filter(R.isNot(R.isIncludedIn(other)))`. Will be removed in v2!
+ * ! **DEPRECATED**: Use `R.difference.multiset(other)` (or `R.filter(R.isNot(R.isIncludedIn(other)))` to keep the current runtime logic). `R.difference.multiset` will replace `R.difference` in v2!
  *
  * @param other - The values to exclude.
  * @signature
- *    R.difference(other)(array)
+ *    R.difference(other)(data)
+ *    R.difference.multiset(other)(data)
  * @example
- *    R.difference([2, 5, 3])([1, 2, 3, 4]) // => [1, 4]
- *    R.pipe(
- *      [1, 2, 3, 4, 5, 6], // only 4 iterations
- *      R.difference([2, 3]),
- *      R.take(2)
- *    ) // => [1, 4]
- * @dataLast
+ *    R.pipe([1, 2, 3, 4], R.difference([2, 5, 3])); // => [1, 4]
+ *    R.pipe([1, 1, 2, 2], R.difference([1])); // => [2, 2]
+ *    R.pipe([1, 1, 2, 2], R.difference.multiset([1])); // => [1, 2, 2]
+ * @dataFirst
  * @pipeable
- * @category Deprecated
- * @deprecated Use `R.filter(R.isNot(R.isIncludedIn(other)))`. Will be removed in v2.
+ * @category Array
+ * @deprecated Use `R.difference.multiset(other)` (or `R.filter(R.isNot(R.isIncludedIn(other)))` to keep the current runtime logic). `R.difference.multiset` will replace `R.difference` in v2!
  */
 export function difference<T, K>(
   other: ReadonlyArray<T>,
@@ -67,4 +79,52 @@ export namespace difference {
         ? { done: false, hasNext: false }
         : { done: false, hasNext: true, next: value };
   }
+
+  export function multiset<T>(
+    array: ReadonlyArray<T>,
+    other: ReadonlyArray<T>,
+  ): Array<T>;
+  export function multiset<T>(
+    other: ReadonlyArray<T>,
+  ): (data: ReadonlyArray<T>) => Array<T>;
+
+  export function multiset(): unknown {
+    return purry(multisetImplementation, arguments, multisetLazyImplementation);
+  }
+}
+
+const multisetImplementation = <T>(
+  array: ReadonlyArray<T>,
+  other: ReadonlyArray<T>,
+): Array<T> => _reduceLazy(array, multisetLazyImplementation(other));
+
+function multisetLazyImplementation<T>(
+  other: ReadonlyArray<T>,
+): LazyEvaluator<T> {
+  if (other.length === 0) {
+    return lazyIdentityEvaluator;
+  }
+
+  // We need to build a more efficient data structure that would allow us to
+  // keep track of the number of times we've seen a value in the other array.
+  const remaining = new Map<T, number>();
+  for (const value of other) {
+    remaining.set(value, (remaining.get(value) ?? 0) + 1);
+  }
+
+  return (value) => {
+    const copies = remaining.get(value);
+
+    if (copies === undefined || copies === 0) {
+      // The item is either not part of the other array or we've dropped enough
+      // copies of it so we return it.
+      return { done: false, hasNext: true, next: value };
+    }
+
+    // The item is equal to an item in the other array and there are still
+    // copies of it to "account" for so we skip this one and remove it from our
+    // ongoing tally.
+    remaining.set(value, copies - 1);
+    return { done: false, hasNext: false };
+  };
 }
diff --git a/src/intersection.ts b/src/intersection.ts
index bb87b5a..1b9733c 100644
--- a/src/intersection.ts
+++ b/src/intersection.ts
@@ -1,45 +1,59 @@
-/* eslint-disable jsdoc/check-param-names, jsdoc/require-param -- ignore for deprecated files */
-
 import { _reduceLazy } from "./_reduceLazy";
+import { lazyEmptyEvaluator } from "./_utilityEvaluators";
 import type { LazyEvaluator } from "./pipe";
 import { purry } from "./purry";
 
 /**
- * Returns a list of elements that exist in both array.
+ * Returns a list of elements that exist in both array. The output maintains the
+ * same order as the input. If either `array` or `other` contain multiple items
+ * with the same values, all occurrences of those values will be present. If the
+ * exact number of copies should be observed (i.e. multi-set semantics), use
+ * `R.intersection.multiset` instead. If the arrays don't contain duplicates,
+ * both implementations yield the same result.
  *
- * ! **DEPRECATED**: Use `R.filter(array, R.isIncludedIn(other))`. Will be removed in v2!
+ * ! **DEPRECATED**: Use `R.intersection.multiset(data, other)` (or `R.filter(data, R.isIncludedIn(other))` to keep the current runtime logic). `R.intersection.multiset` will replace `R.intersection` in v2!
  *
- * @param array - The source array.
- * @param other - The second array.
+ * @param data - The input items.
+ * @param other - The items to compare against.
  * @signature
- *    R.intersection(array, other)
+ *    R.intersection(data, other)
+ *    R.intersection.multiset(data, other)
  * @example
- *    R.intersection([1, 2, 3], [2, 3, 5]) // => [2, 3]
+ *    R.intersection([1, 2, 3], [2, 3, 5]); // => [2, 3]
+ *    R.intersection([1, 1, 2, 2], [1]); // => [1, 1]
+ *    R.intersection.multiset([1, 1, 2, 2], [1]); // => [1]
  * @dataFirst
  * @pipeable
- * @category Deprecated
- * @deprecated Use `R.filter(array, R.isIncludedIn(other))`. Will be removed in v2.
+ * @category Array
+ * @deprecated Use `R.intersection.multiset(data, other)` (or `R.filter(data, R.isIncludedIn(other))` to keep the current runtime logic). `R.intersection.multiset` will replace `R.intersection` in v2!
  */
 export function intersection<T>(
-  source: ReadonlyArray<T>,
+  data: ReadonlyArray<T>,
   other: ReadonlyArray<T>,
 ): Array<T>;
 
 /**
- * Returns a list of elements that exist in both array.
+ * Returns a list of elements that exist in both array. The output maintains the
+ * same order as the input. If either `array` or `other` contain multiple items
+ * with the same values, all occurrences of those values will be present. If the
+ * exact number of copies should be observed (i.e. multi-set semantics), use
+ * `R.intersection.multiset` instead. If the arrays don't contain duplicates,
+ * both implementations yield the same result.
  *
- * ! **DEPRECATED**: Use `R.filter(R.isIncludedIn(other))`. Will be removed in v2!
+ * ! **DEPRECATED**: Use `R.intersection.multiset(other)` (or `R.filter(R.isIncludedIn(other))` to keep the current runtime logic). `R.intersection.multiset` will replace `R.intersection` in v2!
  *
- * @param array - The source array.
- * @param other - The second array.
+ * @param other - The items to compare against.
  * @signature
- *    R.intersection(other)(array)
+ *    R.intersection(other)(data)
+ *    R.intersection.multiset(other)(data)
  * @example
- *    R.intersection([2, 3, 5])([1, 2, 3]) // => [2, 3]
- * @dataLast
+ *    R.pipe([1, 2, 3], R.intersection([2, 3, 5])); // => [2, 3]
+ *    R.pipe([1, 1, 2, 2], R.intersection([1])); // => [1, 1]
+ *    R.pipe([1, 1, 2, 2], R.intersection.multiset([1])); // => [1]
+ * @dataFirst
  * @pipeable
- * @category Deprecated
- * @deprecated Use `R.filter(R.isIncludedIn(other))`. Will be removed in v2.
+ * @category Array
+ * @deprecated Use `R.intersection.multiset(other)` (or `R.filter(R.isIncludedIn(other))` to keep the current runtime logic). `R.intersection.multiset` will replace `R.intersection` in v2!
  */
 export function intersection<T, K>(
   other: ReadonlyArray<T>,
@@ -65,4 +79,64 @@ export namespace intersection {
         ? { done: false, hasNext: true, next: value }
         : { done: false, hasNext: false };
   }
+
+  export function multiset<T, S>(
+    array: ReadonlyArray<T>,
+    other: ReadonlyArray<S>,
+  ): Array<S & T>;
+  export function multiset<S>(
+    other: ReadonlyArray<S>,
+  ): <T>(data: ReadonlyArray<T>) => Array<S & T>;
+
+  export function multiset(): unknown {
+    return purry(multisetImplementation, arguments, multisetLazyImplementation);
+  }
+}
+
+const multisetImplementation = <T, S>(
+  array: ReadonlyArray<T>,
+  other: ReadonlyArray<S>,
+): Array<S & T> => _reduceLazy(array, multisetLazyImplementation(other));
+
+function multisetLazyImplementation<T, S>(
+  other: ReadonlyArray<S>,
+): LazyEvaluator<T, S & T> {
+  if (other.length === 0) {
+    return lazyEmptyEvaluator;
+  }
+
+  // We need to build a more efficient data structure that would allow us to
+  // keep track of the number of times we've seen a value in the other array.
+  const remaining = new Map<S | T, number>();
+  for (const value of other) {
+    remaining.set(value, (remaining.get(value) ?? 0) + 1);
+  }
+
+  return (value) => {
+    const copies = remaining.get(value);
+
+    if (copies === undefined || copies === 0) {
+      // The item is either not part of the other array or we've "used" enough
+      // copies of it so we skip the remaining values.
+      return { done: false, hasNext: false };
+    }
+
+    // The item is equal to an item in the other array and there are still
+    // copies of it to "account" for so we return this one and remove it from
+    // our ongoing tally.
+    if (copies === 1) {
+      remaining.delete(value);
+    } else {
+      remaining.set(value, copies - 1);
+    }
+
+    return {
+      hasNext: true,
+      // We can safely cast here because if value was in the `remaining` map, it
+      // has to be of type S (that's just how we built it).
+      next: value as S & T,
+      // We can stop the iteration if the remaining map is empty.
+      done: remaining.size === 0,
+    };
+  };
 }
diff --git a/src/isDeepEqual.ts b/src/isDeepEqual.ts
index 32382ba..e439dfa 100644
--- a/src/isDeepEqual.ts
+++ b/src/isDeepEqual.ts
@@ -7,9 +7,9 @@ import { purry } from "./purry";
  * 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.
+ * !IMPORTANT: Sets, 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.
@@ -38,9 +38,9 @@ export function isDeepEqual<T, S extends T = T>(data: T, other: S): boolean;
  * 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.
+ * !IMPORTANT: Sets, 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.
@@ -121,6 +121,10 @@ function isDeepEqualImplementation<T, S>(data: S | T, other: S): data is S {
     return data.toString() === (other as unknown as RegExp).toString();
   }
 
+  if (data instanceof Map) {
+    return isDeepEqualMaps(data, other as unknown as Map<unknown, unknown>);
+  }
+
   // 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
@@ -146,3 +150,27 @@ function isDeepEqualImplementation<T, S>(data: S | T, other: S): data is S {
 
   return true;
 }
+
+function isDeepEqualMaps(
+  data: ReadonlyMap<unknown, unknown>,
+  other: ReadonlyMap<unknown, unknown>,
+): boolean {
+  if (data.size !== other.size) {
+    return false;
+  }
+
+  // TODO: Once we bump our typescript target we can iterate over the map keys and values directly.
+  const keys = Array.from(data.keys());
+
+  for (const key of keys) {
+    if (!other.has(key)) {
+      return false;
+    }
+
+    if (!isDeepEqualImplementation(data.get(key), other.get(key))) {
+      return false;
+    }
+  }
+
+  return true;
+}


Test patch:
diff --git a/src/difference.test.ts b/src/difference.test.ts
index bffb873..5dd2008 100644
--- a/src/difference.test.ts
+++ b/src/difference.test.ts
@@ -1,21 +1,18 @@
 import { difference } from "./difference";
+import { identity } from "./identity";
 import { map } from "./map";
 import { pipe } from "./pipe";
 import { take } from "./take";
 
-const source = [1, 2, 3, 4] as const;
-const other = [2, 5, 3] as const;
-const expected = [1, 4] as const;
-
 describe("data_first", () => {
   test("should return difference", () => {
-    expect(difference(source, other)).toEqual(expected);
+    expect(difference([1, 2, 3, 4], [2, 5, 3])).toEqual([1, 4]);
   });
 });
 
 describe("data_last", () => {
   test("should return difference", () => {
-    expect(difference(other)(source)).toEqual(expected);
+    expect(difference([2, 5, 3])([1, 2, 3, 4])).toEqual([1, 4]);
   });
 
   test("lazy", () => {
@@ -33,3 +30,80 @@ describe("data_last", () => {
     expect(result).toEqual([1, 4]);
   });
 });
+
+describe("multiset", () => {
+  it("returns empty array on empty input", () => {
+    expect(difference.multiset([], [1, 2, 3])).toStrictEqual([]);
+  });
+
+  it("removes nothing on empty other array", () => {
+    expect(difference.multiset([1, 2, 3], [])).toStrictEqual([1, 2, 3]);
+  });
+
+  it("returns a shallow clone when nothing is removed", () => {
+    const data = [1, 2, 3];
+    const result = difference.multiset(data, [4, 5, 6]);
+    expect(result).toStrictEqual(data);
+    expect(result).not.toBe(data);
+  });
+
+  it("removes an item that is in the input", () => {
+    expect(difference.multiset([1], [1])).toStrictEqual([]);
+  });
+
+  it("doesn't remove items that are not in the other array", () => {
+    expect(difference.multiset([1], [2])).toStrictEqual([1]);
+  });
+
+  it("maintains multi-set semantics (removes only one copy)", () => {
+    expect(difference.multiset([1, 1, 2, 2], [1])).toStrictEqual([1, 2, 2]);
+  });
+
+  it("works if the other array has more copies than the input", () => {
+    expect(difference.multiset([1], [1, 1])).toStrictEqual([]);
+  });
+
+  it("preserves the original order in source array", () => {
+    expect(difference.multiset([3, 1, 2, 2, 4], [2])).toStrictEqual([
+      3, 1, 2, 4,
+    ]);
+  });
+
+  it("accounts and removes multiple copies", () => {
+    expect(
+      difference.multiset([1, 2, 3, 1, 2, 3, 1, 2, 3], [1, 2, 1, 2]),
+    ).toStrictEqual([3, 3, 1, 2, 3]);
+  });
+
+  it("works with strings", () => {
+    expect(difference.multiset(["a", "b", "c"], ["b"])).toStrictEqual([
+      "a",
+      "c",
+    ]);
+  });
+
+  it("works with objects", () => {
+    const item = { a: 2 };
+    expect(
+      difference.multiset([item, { b: 3 }, item], [item, item]),
+    ).toStrictEqual([{ b: 3 }]);
+  });
+
+  it("compares objects by reference", () => {
+    expect(difference.multiset([{ a: 2 }, { b: 3 }], [{ a: 2 }])).toStrictEqual(
+      [{ a: 2 }, { b: 3 }],
+    );
+  });
+});
+
+test("lazy", () => {
+  const mock = vi.fn(identity);
+  const result = pipe(
+    [1, 2, 3, 4, 5, 6],
+    map(mock),
+    difference.multiset([2, 3]),
+    take(2),
+  );
+  expect(mock).toHaveBeenCalledTimes(4);
+  expect(result).toEqual([1, 4]);
+});
diff --git a/src/intersection.test.ts b/src/intersection.test.ts
index aac69e3..37941dc 100644
--- a/src/intersection.test.ts
+++ b/src/intersection.test.ts
@@ -1,4 +1,8 @@
+import { identity } from "./identity";
 import { intersection } from "./intersection";
+import { map } from "./map";
+import { pipe } from "./pipe";
+import { take } from "./take";
 
 describe("data_first", () => {
   test("intersection", () => {
@@ -15,3 +19,81 @@ describe("data_last", () => {
     ]);
   });
 });
+
+describe("multiset", () => {
+  describe("runtime", () => {
+    it("returns empty array trivially", () => {
+      expect(intersection.multiset([], [])).toStrictEqual([]);
+    });
+
+    it("returns empty array on empty input", () => {
+      expect(intersection.multiset([], [1, 2, 3])).toStrictEqual([]);
+      expect(intersection.multiset([1, 2, 3], [])).toStrictEqual([]);
+    });
+
+    it("returns empty array on disjoint arrays", () => {
+      expect(intersection.multiset([1], [2])).toStrictEqual([]);
+    });
+
+    it("works trivially on a single item", () => {
+      expect(intersection.multiset([1], [1])).toStrictEqual([1]);
+    });
+
+    it("maintains multi-set semantics (returns only one copy)", () => {
+      expect(intersection.multiset([1, 1], [1])).toStrictEqual([1]);
+      expect(intersection.multiset([1], [1, 1])).toStrictEqual([1]);
+    });
+
+    it("maintains multi-set semantics (returns as many copies as available)", () => {
+      expect(intersection.multiset([1, 1, 1, 1, 1], [1, 1])).toStrictEqual([
+        1, 1,
+      ]);
+      expect(intersection.multiset([1, 1], [1, 1, 1, 1, 1])).toStrictEqual([
+        1, 1,
+      ]);
+    });
+
+    it("preserves the original order in source array", () => {
+      expect(intersection.multiset([3, 2, 1], [1, 2, 3])).toStrictEqual([
+        3, 2, 1,
+      ]);
+    });
+
+    it("maintains order for multiple copies", () => {
+      expect(
+        intersection.multiset([3, 2, 2, 2, 2, 2, 1], [1, 2, 3]),
+      ).toStrictEqual([3, 2, 1]);
+    });
+
+    it("returns a shallow copy even when all items match", () => {
+      const data = [1, 2, 3];
+      const result = intersection.multiset(data, [1, 2, 3]);
+      expect(result).toStrictEqual(data);
+      expect(result).not.toBe(data);
+    });
+  });
+
+  describe("piping", () => {
+    test("lazy", () => {
+      const mock = vi.fn(identity);
+      const result = pipe(
+        [1, 2, 3, 4, 5, 6],
+        map(mock),
+        intersection.multiset([4, 2]),
+        take(2),
+      );
+      expect(mock).toHaveBeenCalledTimes(4);
+      expect(result).toStrictEqual([2, 4]);
+    });
+  });
+
+  describe("typing", () => {
+    it("narrows the result type", () => {
+      const result = intersection.multiset(
+        [1, 2, 3, "a", "b"],
+        ["a", "b", true, false],
+      );
+      expectTypeOf(result).toEqualTypeOf<Array<string>>();
+    });
+  });
+});
diff --git a/src/isDeepEqual.test.ts b/src/isDeepEqual.test.ts
index 68bd93a..3c7a461 100644
--- a/src/isDeepEqual.test.ts
+++ b/src/isDeepEqual.test.ts
@@ -155,6 +155,69 @@ describe("arrays", () => {
   });
 });
 
+describe("Maps", () => {
+  it("works on shallow equal maps", () => {
+    expect(isDeepEqual(new Map([["a", 1]]), new Map([["a", 1]]))).toBe(true);
+  });
+
+  test("two empty Maps should be equal", () => {
+    expect(isDeepEqual(new Map(), new Map())).toBe(true);
+  });
+
+  it("two Maps with different size should not be equal", () => {
+    expect(isDeepEqual(new Map(), new Map([["a", 1]]))).toBe(false);
+  });
+
+  it("two Maps with different keys shoud not be equal", () => {
+    expect(
+      isDeepEqual(
+        new Map([
+          ["a", 1],
+          ["c", 2],
+        ]),
+        new Map([
+          ["a", 1],
+          ["b", 2],
+        ]),
+      ),
+    ).toBe(false);
+  });
+
+  it("two maps with the same keys but with different values should not be equal", () => {
+    expect(
+      isDeepEqual(
+        new Map([
+          ["a", 1],
+          ["b", 3],
+          ["c", 2],
+        ]),
+        new Map([
+          ["a", 1],
+          ["b", 2],
+          ["c", 2],
+        ]),
+      ),
+    ).toBe(false);
+  });
+
+  it("two Maps with the same non primitives data should be equal", () => {
+    expect(
+      isDeepEqual(
+        new Map([
+          ["a", { a: [1, 2, 3] }],
+          ["b", { a: [3] }],
+          ["c", { b: [4] }],
+        ]),
+        new Map([
+          ["a", { a: [1, 2, 3] }],
+          ["b", { a: [3] }],
+          ["c", { b: [4] }],
+        ]),
+      ),
+    ).toBe(true);
+  });
+});
+
 describe("Date objects", () => {
   test("equal date objects", () => {
     expect(
diff --git a/src/isIncludedIn.test.ts b/src/isIncludedIn.test.ts
index 087adb4..a5b6bdb 100644
--- a/src/isIncludedIn.test.ts
+++ b/src/isIncludedIn.test.ts
@@ -1,5 +1,9 @@
+import { filter } from "./filter";
 import { isIncludedIn } from "./isIncludedIn";
+import { isNot } from "./isNot";
+import { map } from "./map";
 import { pipe } from "./pipe";
+import { take } from "./take";
 
 describe("dataFirst", () => {
   test("empty containers", () => {
@@ -217,3 +221,56 @@ describe("typing", () => {
     });
   });
 });
+
+// These tests are copy-pasted and adapted from existing tests of functions
+// that have been deprecated and removed from in v2. They validate that we
+// provide replacements for deprecated v1 functions. After v2 is out for a while
+// they could be removed.
+describe("legacy v1 replacements", () => {
+  describe("difference", () => {
+    describe("data_first", () => {
+      test("should return difference", () => {
+        expect(filter([1, 2, 3, 4], isNot(isIncludedIn([2, 5, 3])))).toEqual([
+          1, 4,
+        ]);
+      });
+    });
+
+    describe("data_last", () => {
+      test("should return difference", () => {
+        expect(filter(isNot(isIncludedIn([2, 5, 3])))([1, 2, 3, 4])).toEqual([
+          1, 4,
+        ]);
+      });
+
+      test("lazy", () => {
+        const count = vi.fn();
+        const result = pipe(
+          [1, 2, 3, 4, 5, 6],
+          map((x) => {
+            count();
+            return x;
+          }),
+          filter(isNot(isIncludedIn([2, 3]))),
+          take(2),
+        );
+        expect(count).toHaveBeenCalledTimes(4);
+        expect(result).toEqual([1, 4]);
+      });
+    });
+  });
+
+  describe("intersection", () => {
+    describe("data_first", () => {
+      test("intersection", () => {
+        expect(filter([1, 2, 3], isIncludedIn([2, 3, 5]))).toEqual([2, 3]);
+      });
+    });
+
+    describe("data_last", () => {
+      test("intersection", () => {
+        expect(filter(isIncludedIn([2, 3, 5]))([1, 2, 3])).toEqual([2, 3]);
+      });
+    });
+  });
+});