Repository: remeda/remeda
Pull request #568 (2024-03-11 18:27:53)

feat: isIncludedIn
A type-guard that checks existence in a set of values. Useful for narrowing possible enum (literal union) values via a global const.

Optimizes dataFirst and dataLast implementations offering better performance for each case.

Deprecated intersection and difference in favour of using filter in combination with isIncludedIn so we can take advantage of the performance gains and optimizations, and to make room for future interestion and difference functions which will work more closely to the mathematical definitions of multi-sets (which take into account duplicate items, as could be present in arrays). See #368 

---

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`
- [ ] 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/568/builds/482416) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit df715ae82bf94fe9dea90782c39eb4e51aeb0900:

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

cjquines: hm, why deprecate? `R.filter(array, R.isNot(R.isIncludedIn(other)))` vs `R.difference(array, other)` are quite different reading-wise

i suppose i'm still confused with the philosophy of which functions are included; we have `R.add` and `R.prop` which are ts one-liners (`(x, y) => x + y` and `({ [prop]: value }) => value`), but we don't have things like `R.sum`
eranhirsch: 1. For a while, we opposed having stuff like `add` and `subtract` for exactly the reasons you mentioned. The mapping file still has a section for "simple one-liners," but from my experience in real-life projects, the code ends up being less readable requiring (`map((item) => item + 3)` vs `map(add(3))`, where we don't need to come up with a name for `item` just to add 3 to it). Without a utility, less experienced engineers might think that what they are doing is wrong or discouraged by the system ("if it's not built into the project, it might be incorrect to use it").

2. Conversely, I prefer we provide good building blocks that are **very reusable** over packaging several semantic "tools" into a single "solution". This is more apparent with `reject`, for example; we don't need to provide both a `filter` function and a "filter but reversed" function when we can provide a utility to flip a predicate (`isNot`). This allows us to optimize the implementation and typing of `filter` without needing to backport these optimizations to each sub-case separately; And lets people use `isNot` for *other solutions* without needing to re-package those tools in a new utility "solution".

3. Finally, specifically for `difference` and `intersects`, I feel they have incorrect semantics: they operate on arrays and not sets but act like a hybrid of a set and a multi-set: `difference([1,1,1,1], [1]); // []` but `difference([1,1,2,2], [1]) // [2, 2]`. Forcing people to use `filter` to get the same results makes it *clearer* what these functions do (`filter([1,1,1,1], isNot(isIncludedIn([1])))`). For the person writing the code that uses `intersection`, the semantics might be clear as they read the docs, but for someone reading the code, the fact that the semantics aren't clear is actually confusing and might lead to bad assumptions. My plan is to add new implementations for these functions that have the correct multi-set semantics; having those in addition to the `filter` "recipe" provided here would allow users to pick which logic they need for their use-case. See #368 

Patch:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 6964d80..d154e20 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -67,6 +67,7 @@ export default config(
       "unicorn/prefer-at": "off",
       "unicorn/prefer-number-properties": "off",
       "unicorn/prefer-spread": "off",
+      "unicorn/prefer-includes": "off",
 
       // TODO: These rules allow us to really standardize our codebase, but they
       // also do sweeping changes to the whole codebase which is very noisy. We
diff --git a/src/difference.ts b/src/difference.ts
index cb6e582..60c86cf 100644
--- a/src/difference.ts
+++ b/src/difference.ts
@@ -5,6 +5,9 @@ import { purry } from "./purry";
 /**
  * Excludes the values from `other` array.
  *
+ * **DEPRECATED: equivalent to `R.filter(array, R.isNot(R.isIncludedIn(other)))`
+ * and will be removed in v2.**.
+ *
  * @param array - The source array.
  * @param other - The values to exclude.
  * @signature
@@ -14,6 +17,7 @@ import { purry } from "./purry";
  * @dataFirst
  * @pipeable
  * @category Array
+ * @deprecated Equivalent to `R.filter(array, R.isNot(R.isIncludedIn(other)))` and will be removed in v2.
  */
 export function difference<T>(
   array: ReadonlyArray<T>,
@@ -23,6 +27,9 @@ export function difference<T>(
 /**
  * Excludes the values from `other` array.
  *
+ * **DEPRECATED: equivalent to `R.filter(R.isNot(R.isIncludedIn(other)))` and
+ * will be removed in v2.**.
+ *
  * @param other - The values to exclude.
  * @signature
  *    R.difference(other)(array)
@@ -36,6 +43,7 @@ export function difference<T>(
  * @dataLast
  * @pipeable
  * @category Array
+ * @deprecated Equivalent to `R.filter(R.isNot(R.isIncludedIn(other)))` and will be removed in v2.
  */
 export function difference<T, K>(
   other: ReadonlyArray<T>,
diff --git a/src/index.ts b/src/index.ts
index 073aa8d..ec584a8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -51,6 +51,7 @@ export * from "./isDefined";
 export * from "./isEmpty";
 export * from "./isError";
 export * from "./isFunction";
+export * from "./isIncludedIn";
 export * from "./isNil";
 export * from "./isNonNull";
 export * from "./isNot";
diff --git a/src/intersection.ts b/src/intersection.ts
index 9fbc50a..a22494b 100644
--- a/src/intersection.ts
+++ b/src/intersection.ts
@@ -5,6 +5,9 @@ import { purry } from "./purry";
 /**
  * Returns a list of elements that exist in both array.
  *
+ * **DEPRECATED: equivalent to `R.filter(array, R.isIncludedIn(other))` and will
+ * be removed in v2.**.
+ *
  * @param array - The source array.
  * @param other - The second array.
  * @signature
@@ -14,6 +17,7 @@ import { purry } from "./purry";
  * @dataFirst
  * @pipeable
  * @category Array
+ * @deprecated Equivalent to `R.filter(array, R.isIncludedIn(other))` and will be removed in v2.
  */
 export function intersection<T>(
   source: ReadonlyArray<T>,
@@ -23,6 +27,9 @@ export function intersection<T>(
 /**
  * Returns a list of elements that exist in both array.
  *
+ * **DEPRECATED: equivalent to `R.filter(R.isIncludedIn(other))` and will be
+ * removed in v2.**.
+ *
  * @param array - The source array.
  * @param other - The second array.
  * @signature
@@ -32,6 +39,7 @@ export function intersection<T>(
  * @dataLast
  * @pipeable
  * @category Array
+ * @deprecated Equivalent to `R.filter(R.isIncludedIn(other))` and will be removed in v2.
  */
 export function intersection<T, K>(
   other: ReadonlyArray<T>,
diff --git a/src/isIncludedIn.ts b/src/isIncludedIn.ts
new file mode 100644
index 0000000..3b86a66
--- /dev/null
+++ b/src/isIncludedIn.ts
@@ -0,0 +1,78 @@
+import type { IterableContainer } from "./_types";
+
+/**
+ * Checks if the item is included in the container. This is a wrapper around
+ * `Array.prototype.includes` and `Set.prototype.has` and thus relies on the
+ * same equality checks that those functions do (which is reference equality,
+ * e.g. `===`). The input's type is narrowed to the container's type if
+ * possible.
+ *
+ * Notice that unlike most functions, this function takes a generic item as it's
+ * data and **an array** as it's parameter.
+ *
+ * @param data - The item that is checked.
+ * @param container - The items that are checked against.
+ * @returns A narrowed version of the input data on success, `false` otherwise.
+ * @signature
+ *   R.isIncludedIn(data, container);
+ * @example
+ *   R.isIncludedIn(2, [1, 2, 3]); // => true
+ *   R.isIncludedIn(4, [1, 2, 3]); // => false
+ *
+ *   const data = "cat" as "cat" | "dog" | "mouse";
+ *   R.isIncludedIn(data, ["cat", "dog"] as const); // true (typed "cat" | "dog");
+ * @dataFirst
+ * @category Guard
+ */
+export function isIncludedIn<T, S extends T>(
+  data: T,
+  container: IterableContainer<S>,
+): data is S;
+
+/**
+ * Checks if the item is included in the container. This is a wrapper around
+ * `Array.prototype.includes` and `Set.prototype.has` and thus relies on the
+ * same equality checks that those functions do (which is reference equality,
+ * e.g. `===`). The input's type is narrowed to the container's type if
+ * possible.
+ *
+ * Notice that unlike most functions, this function takes a generic item as it's
+ * data and **an array** as it's parameter.
+ *
+ * @param data - The item that is checked.
+ * @param container - The items that are checked against.
+ * @returns A narrowed version of the input data on success, `false` otherwise.
+ * @signature
+ *   R.isIncludedIn(container)(data);
+ * @example
+ *   R.pipe(2, R.isIncludedIn([1, 2, 3])); // => true
+ *   R.pipe(4, R.isIncludedIn([1, 2, 3])); // => false
+ *
+ *   const data = "cat" as "cat" | "dog" | "mouse";
+ *   R.pipe(
+ *     data,
+ *     R.isIncludedIn(["cat", "dog"] as const),
+ *   ); // => true (typed "cat" | "dog");
+ * @dataLast
+ * @category Guard
+ */
+export function isIncludedIn<T, S extends T>(
+  container: IterableContainer<S>,
+): (data: T) => data is S;
+
+export function isIncludedIn(
+  dataOrContainer: unknown,
+  container?: ReadonlyArray<unknown>,
+): unknown {
+  if (container === undefined) {
+    // === dataLast ===
+    // We don't use purry here because we can optimize the dataLast case by
+    // memoizing a set and accessing it in O(1) time instead of scanning the
+    // array **each time** (O(n)) each time.
+    const asSet = new Set(dataOrContainer as ReadonlyArray<unknown>);
+    return (data: unknown) => asSet.has(data);
+  }
+
+  // === dataFirst ===
+  return container.indexOf(dataOrContainer) >= 0;
+}
diff --git a/src/mapWithFeedback.ts b/src/mapWithFeedback.ts
index e028c99..9295497 100644
--- a/src/mapWithFeedback.ts
+++ b/src/mapWithFeedback.ts
@@ -6,9 +6,10 @@ import { purry } from "./purry";
 
 /**
  * Applies a function on each element of the array, using the result of the previous application, and returns an array of the successively computed values.
- * @param array the array to map over
- * @param reducer the callback function that receives the previous value, the current element, and optionally the index and the whole array
- * @param initialValue the initial value to start the computation with
+ *
+ * @param array - The array to map over.
+ * @param reducer - The callback function that receives the previous value, the current element, and optionally the index and the whole array.
+ * @param initialValue - The initial value to start the computation with.
  * @returns An array of successively computed values from the left side of the array.
  * @signature
  *    R.mapWithFeedback(items, fn, initialValue)
@@ -29,8 +30,9 @@ export function mapWithFeedback<T extends IterableContainer, U>(
 
 /**
  * Applies a function on each element of the array, using the result of the previous application, and returns an array of the successively computed values.
- * @param reducer the callback function that receives the previous value, the current element, and optionally the index and the whole array
- * @param initialValue the initial value to start the computation with
+ *
+ * @param reducer - The callback function that receives the previous value, the current element, and optionally the index and the whole array.
+ * @param initialValue - The initial value to start the computation with.
  * @returns An array of successively computed values from the left side of the array.
  * @signature
  *    R.mapWithFeedback(fn, initialValue)(array)


Test patch:
diff --git a/src/isIncludedIn.test.ts b/src/isIncludedIn.test.ts
new file mode 100644
index 0000000..a0c7950
--- /dev/null
+++ b/src/isIncludedIn.test.ts
@@ -0,0 +1,119 @@
+import { isIncludedIn } from "./isIncludedIn";
+import { pipe } from "./pipe";
+
+describe("dataFirst", () => {
+  test("empty containers", () => {
+    expect(isIncludedIn(1, [])).toBe(false);
+  });
+
+  test("when contained", () => {
+    expect(isIncludedIn(2, [1, 2, 3])).toBe(true);
+  });
+
+  test("when not contained", () => {
+    expect(isIncludedIn(4, [1, 2, 3])).toBe(false);
+  });
+
+  it("works with strings", () => {
+    expect(isIncludedIn("b", ["a", "b", "c"])).toBe(true);
+  });
+
+  it("only tests reference equality: (arrays)", () => {
+    const arr = [1];
+    expect(isIncludedIn([1], [arr])).toBe(false);
+    expect(isIncludedIn(arr, [arr])).toBe(true);
+  });
+
+  it("only tests reference equality: (objects)", () => {
+    const obj = { a: 1 };
+    expect(isIncludedIn({ a: 1 }, [obj])).toBe(false);
+    expect(isIncludedIn(obj, [obj])).toBe(true);
+  });
+});
+
+describe("dataLast", () => {
+  test("empty containers", () => {
+    expect(pipe(1, isIncludedIn([]))).toBe(false);
+  });
+
+  test("when contained", () => {
+    expect(pipe(2, isIncludedIn([1, 2, 3]))).toBe(true);
+  });
+
+  test("when not contained", () => {
+    expect(pipe(4, isIncludedIn([1, 2, 3]))).toBe(false);
+  });
+
+  it("works with strings", () => {
+    expect(pipe("b", isIncludedIn(["a", "b", "c"]))).toBe(true);
+  });
+
+  it("only tests reference equality: (arrays)", () => {
+    const arr = [1];
+    expect(pipe([1], isIncludedIn([arr]))).toBe(false);
+    expect(pipe(arr, isIncludedIn([arr]))).toBe(true);
+  });
+
+  it("only tests reference equality: (objects)", () => {
+    const obj = { a: 1 };
+    expect(pipe({ a: 1 }, isIncludedIn([obj]))).toBe(false);
+    expect(pipe(obj, isIncludedIn([obj]))).toBe(true);
+  });
+
+  describe("dataLast memoization", () => {
+    it("returns correct result when called multiple times with the same container", () => {
+      const isIncludedInContainer = isIncludedIn([1, 2, 3]);
+      expect(isIncludedInContainer(2)).toBe(true);
+      expect(isIncludedInContainer(4)).toBe(false);
+    });
+
+    it("returns correct result when called with different containers", () => {
+      const isIncludedInContainer1 = isIncludedIn([1, 2, 3]);
+      const isIncludedInContainer2 = isIncludedIn([4, 5, 6]);
+      expect(isIncludedInContainer1(2)).toBe(true);
+      expect(isIncludedInContainer2(2)).toBe(false);
+      expect(isIncludedInContainer1(4)).toBe(false);
+      expect(isIncludedInContainer2(4)).toBe(true);
+    });
+
+    it("does not leak information between invocations", () => {
+      const container = [1, 2, 3];
+
+      const isIncludedInContainer = isIncludedIn(container);
+
+      expect(isIncludedInContainer(2)).toBe(true);
+      expect(isIncludedInContainer(4)).toBe(false);
+
+      // Modifying the original container should not affect the memoized function
+      container.push(4);
+
+      expect(isIncludedInContainer(2)).toBe(true);
+      expect(isIncludedInContainer(4)).toBe(false);
+    });
+  });
+});
+
+describe("typing", () => {
+  it("narrows the result", () => {
+    const data = 4 as 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
+
+    if (isIncludedIn(data, [1, 2, 3])) {
+      expectTypeOf(data).toEqualTypeOf<1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9>();
+    }
+
+    if (isIncludedIn(data, [1, 2, 3] as const)) {
+      expectTypeOf(data).toEqualTypeOf<1 | 2 | 3>();
+    }
+  });
+
+  it("throws on bad value types", () => {
+    // @ts-expect-error [ts2322] - strings are not numbers
+    isIncludedIn(1, ["yes", "no"]);
+  });
+
+  it("throws on non-overlapping (e.g. typo-proof)", () => {
+    const myEnum = "cat" as "cat" | "dog";
+    // @ts-expect-error [ts2322] - "doog" is a typo
+    isIncludedIn(myEnum, ["doog"]);
+  });
+});