Repository: remeda/remeda
Pull request #576 (2024-03-19 11:35:49)

feat: add `pullObject`
A new function the suite of object-creating utilties. Pull provides a simpler API for mapToObj style mappings where both the key and the value need to be computed from an iterable of items.

---

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

Latest deployment of this branch, based on commit 7124879cc645082177d1baa5d6ac4acb33df9fba:

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


Patch:
diff --git a/src/fromEntries.ts b/src/fromEntries.ts
index a6cd47e..92d033c 100644
--- a/src/fromEntries.ts
+++ b/src/fromEntries.ts
@@ -14,6 +14,14 @@ type Entry<Key extends PropertyKey = PropertyKey, Value = unknown> = readonly [
  * The strict option supports more sophisticated use-cases like those that would
  * result when calling the strict `toPairs` function.
  *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `fromKeys` - Builds an object from an array of *keys* and a mapper for values.
+ * * `indexBy` - Builds an object from an array of *values* and a mapper for keys.
+ * * `pullObject` - Builds an object from an array of items with mappers for *both* keys and values.
+ * * `mapToObj` - Builds an object from an array of items and a single mapper for key-value pairs.
+ * Refer to the docs for more details.
+ *
  * @param entries - The list of input tuples.
  * @signature
  *   R.fromEntries(tuples)
@@ -40,6 +48,14 @@ export function fromEntries<V>(
  * The strict option supports more sophisticated use-cases like those that would
  * result when calling the strict `toPairs` function.
  *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `fromKeys` - Builds an object from an array of *keys* and a mapper for values.
+ * * `indexBy` - Builds an object from an array of *values* and a mapper for keys.
+ * * `pullObject` - Builds an object from an array of items with mappers for *both* keys and values.
+ * * `mapToObj` - Builds an object from an array of items and a single mapper for key-value pairs.
+ * Refer to the docs for more details.
+ *
  * @signature
  *   R.fromEntries()(tuples)
  *   R.fromEntries.strict()(tuples)
diff --git a/src/fromKeys.ts b/src/fromKeys.ts
index e386f5a..95ca06c 100644
--- a/src/fromKeys.ts
+++ b/src/fromKeys.ts
@@ -21,6 +21,14 @@ type FromKeys<T extends IterableContainer, V> = T extends readonly []
  * that key. Duplicate keys are overwritten, guaranteeing that `mapper` is run
  * for each item in `data`.
  *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `indexBy` - Builds an object from an array of *values* and a mapper for keys.
+ * * `pullObject` - Builds an object from an array of items with mappers for *both* keys and values.
+ * * `fromEntries` - Builds an object from an array of key-value pairs.
+ * * `mapToObj` - Builds an object from an array of items and a single mapper for key-value pairs.
+ * Refer to the docs for more details.
+ *
  * @param data - An array of keys of the output object. All items in the array
  * would be keys in the output array.
  * @param mapper - Takes a key and returns the value that would be associated
@@ -43,6 +51,14 @@ export function fromKeys<T extends IterableContainer<PropertyKey>, V>(
  * that key. Duplicate keys are overwritten, guaranteeing that `mapper` is run
  * for each item in `data`.
  *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `indexBy` - Builds an object from an array of *values* and a mapper for keys.
+ * * `pullObject` - Builds an object from an array of items with mappers for *both* keys and values.
+ * * `fromEntries` - Builds an object from an array of key-value pairs.
+ * * `mapToObj` - Builds an object from an array of items and a single mapper for key-value pairs.
+ * Refer to the docs for more details.
+ *
  * @param mapper - Takes a key and returns the value that would be associated
  * with that key.
  * @signature
diff --git a/src/fromPairs.ts b/src/fromPairs.ts
index df2ea0b..e1ae1ab 100644
--- a/src/fromPairs.ts
+++ b/src/fromPairs.ts
@@ -14,6 +14,14 @@ type Entry<Key extends PropertyKey = PropertyKey, Value = unknown> = readonly [
  * The strict option supports more sophisticated use-cases like those that would
  * result when calling the strict `toPairs` function.
  *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `fromKeys` - Builds an object from an array of *keys* and a mapper for values.
+ * * `indexBy` - Builds an object from an array of *values* and a mapper for keys.
+ * * `pullObject` - Builds an object from an array of items with mappers for *both* keys and values.
+ * * `mapToObj` - Builds an object from an array of items and a single mapper for key-value pairs.
+ * Refer to the docs for more details.
+ *
  * ! **DEPRECATED**: Use `R.fromEntries(pairs)`, for dataLast invocations use the functional form `R.fromEntries()`. Will be removed in V2!
  *
  * @param pairs - The list of input tuples.
diff --git a/src/index.ts b/src/index.ts
index ea21786..629209b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -103,6 +103,7 @@ export * from "./pipe";
 export * from "./piped";
 export * from "./product";
 export * from "./prop";
+export * from "./pullObject";
 export * from "./purry";
 export * from "./randomString";
 export * from "./range";
diff --git a/src/indexBy.ts b/src/indexBy.ts
index 3174f08..63e85f0 100644
--- a/src/indexBy.ts
+++ b/src/indexBy.ts
@@ -5,6 +5,14 @@ import type { PredIndexedOptional, PredIndexed } from "./_types";
  * Converts a list of objects into an object indexing the objects by the given key (casted to a string).
  * Use the strict version to maintain the given key's type, so long as it is a valid `PropertyKey`.
  *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `fromKeys` - Builds an object from an array of *keys* and a mapper for values.
+ * * `pullObject` - Builds an object from an array of items with mappers for *both* keys and values.
+ * * `fromEntries` - Builds an object from an array of key-value pairs.
+ * * `mapToObj` - Builds an object from an array of items and a single mapper for key-value pairs.
+ * Refer to the docs for more details.
+ *
  * @param array - The array.
  * @param fn - The indexing function.
  * @signature
@@ -28,6 +36,14 @@ export function indexBy<T>(
  * (casted to a string). Use the strict version to maintain the given key's type, so
  * long as it is a valid `PropertyKey`.
  *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `fromKeys` - Builds an object from an array of *keys* and a mapper for values.
+ * * `pullObject` - Builds an object from an array of items with mappers for *both* keys and values.
+ * * `fromEntries` - Builds an object from an array of key-value pairs.
+ * * `mapToObj` - Builds an object from an array of items and a single mapper for key-value pairs.
+ * Refer to the docs for more details.
+ *
  * @param fn - The indexing function.
  * @signature
  *    R.indexBy(fn)(array)
diff --git a/src/mapToObj.ts b/src/mapToObj.ts
index 7d5ba91..fe663b8 100644
--- a/src/mapToObj.ts
+++ b/src/mapToObj.ts
@@ -4,6 +4,14 @@ import { purry } from "./purry";
 /**
  * Map each element of an array into an object using a defined callback function.
  *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `fromKeys` - Builds an object from an array of *keys* and a mapper for values.
+ * * `indexBy` - Builds an object from an array of *values* and a mapper for keys.
+ * * `pullObject` - Builds an object from an array of items with mappers for *both* keys and values.
+ * * `fromEntries` - Builds an object from an array of key-value pairs.
+ * Refer to the docs for more details.
+ *
  * @param array - The array to map.
  * @param fn - The mapping function, which should return a tuple of [key, value], similar to Object.fromEntries.
  * @returns The new mapped object.
@@ -25,6 +33,14 @@ export function mapToObj<T, K extends PropertyKey, V>(
 /**
  * Map each element of an array into an object using a defined callback function.
  *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `fromKeys` - Builds an object from an array of *keys* and a mapper for values.
+ * * `indexBy` - Builds an object from an array of *values* and a mapper for keys.
+ * * `pullObject` - Builds an object from an array of items with mappers for *both* keys and values.
+ * * `fromEntries` - Builds an object from an array of key-value pairs.
+ * Refer to the docs for more details.
+ *
  * @param fn - The mapping function, which should return a tuple of [key, value], similar to Object.fromEntries.
  * @returns The new mapped object.
  * @signature
diff --git a/src/pullObject.ts b/src/pullObject.ts
new file mode 100644
index 0000000..6ce3598
--- /dev/null
+++ b/src/pullObject.ts
@@ -0,0 +1,105 @@
+import type { IterableContainer } from "./_types";
+import { purry } from "./purry";
+
+/**
+ * Creates an object that maps the result of `valueExtractor` with a key
+ * resulting from running `keyExtractor` on each item in `data`. Duplicate keys
+ * are overwritten, guaranteeing that the extractor functions are run on each
+ * item in `data`.
+ *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `fromKeys` - Builds an object from an array of *keys* and a mapper for values.
+ * * `indexBy` - Builds an object from an array of *values* and a mapper for keys.
+ * * `fromEntries` - Builds an object from an array of key-value pairs.
+ * * `mapToObj` - Builds an object from an array of items and a single mapper for key-value pairs.
+ * Refer to the docs for more details.
+ *
+ * @param data - The items used to pull/extract the keys and values from.
+ * @param keyExtractor - Computes the key for item.
+ * @param valueExtractor - Computes the value for the item.
+ * @signature
+ *   R.pullObject(data, keyExtractor, valueExtractor);
+ * @example
+ *   R.pullObject(
+ *     [
+ *       { name: "john", email: "john@remedajs.com" },
+ *       { name: "jane", email: "jane@remedajs.com" }
+ *     ],
+ *     R.prop("name"),
+ *     R.prop("email"),
+ *   ); // => { john: "john@remedajs.com", jane: "jane@remedajs.com" }
+ * @dataFirst
+ * @category Object
+ */
+export function pullObject<
+  T extends IterableContainer,
+  K extends PropertyKey,
+  V,
+>(
+  data: T,
+  keyExtractor: (item: T[number]) => K,
+  valueExtractor: (item: T[number]) => V,
+): Partial<Record<K, V>>;
+
+/**
+ * Creates an object that maps the result of `valueExtractor` with a key
+ * resulting from running `keyExtractor` on each item in `data`. Duplicate keys
+ * are overwritten, guaranteeing that the extractor functions are run on each
+ * item in `data`.
+ *
+ * There are several other functions that could be used to build an object from
+ * an array:
+ * * `fromKeys` - Builds an object from an array of *keys* and a mapper for values.
+ * * `indexBy` - Builds an object from an array of *values* and a mapper for keys.
+ * * `fromEntries` - Builds an object from an array of key-value pairs.
+ * * `mapToObj` - Builds an object from an array of items and a single mapper for key-value pairs.
+ * Refer to the docs for more details.
+ *
+ * @param keyExtractor - Computes the key for item.
+ * @param valueExtractor - Computes the value for the item.
+ * @signature
+ *   R.pullObject(keyExtractor, valueExtractor)(data);
+ * @example
+ *   R.pipe(
+ *     [
+ *       { name: "john", email: "john@remedajs.com" },
+ *       { name: "jane", email: "jane@remedajs.com" }
+ *     ],
+ *     R.pullObject(R.prop("email"), R.prop("name")),
+ *   ); // => { john: "john@remedajs.com", jane: "jane@remedajs.com" }
+ * @dataLast
+ * @category Object
+ */
+export function pullObject<
+  T extends IterableContainer,
+  K extends PropertyKey,
+  V,
+>(
+  keyExtractor: (item: T[number]) => K,
+  valueExtractor: (item: T[number]) => V,
+): (data: T) => Partial<Record<K, V>>;
+
+export function pullObject(): unknown {
+  return purry(pullObjectImplementation, arguments);
+}
+
+function pullObjectImplementation<
+  T extends IterableContainer,
+  K extends PropertyKey,
+  V,
+>(
+  data: T,
+  keyExtractor: (item: T[number]) => K,
+  valueExtractor: (item: T[number]) => V,
+): Partial<Record<K, V>> {
+  const result: Partial<Record<K, V>> = {};
+
+  for (const item of data) {
+    const key = keyExtractor(item);
+    const value = valueExtractor(item);
+    result[key] = value;
+  }
+
+  return result;
+}


Test patch:
diff --git a/src/pullObject.test.ts b/src/pullObject.test.ts
new file mode 100644
index 0000000..2e0f8fb
--- /dev/null
+++ b/src/pullObject.test.ts
@@ -0,0 +1,264 @@
+import { add } from "./add";
+import { constant } from "./constant";
+import { identity } from "./identity";
+import { pipe } from "./pipe";
+import { prop } from "./prop";
+import { pullObject } from "./pullObject";
+
+describe("runtime", () => {
+  describe("dataFirst", () => {
+    test("empty array", () => {
+      expect(pullObject([], constant("key"), constant("value"))).toStrictEqual(
+        {},
+      );
+    });
+
+    test("number items", () => {
+      expect(
+        pullObject([1, 2], (item) => `item_${item}`, add(2)),
+      ).toStrictEqual({
+        item_1: 3,
+        item_2: 4,
+      });
+    });
+
+    test("string items", () => {
+      expect(
+        pullObject(
+          ["a", "b"],
+          (item) => `item_${item}`,
+          (item) => item.toUpperCase(),
+        ),
+      ).toStrictEqual({ item_a: "A", item_b: "B" });
+    });
+
+    test("object items", () => {
+      expect(
+        pullObject(
+          [
+            { key: "a", value: 1 },
+            { key: "b", value: 2 },
+          ],
+          prop("key"),
+          prop("value"),
+        ),
+      ).toStrictEqual({ a: 1, b: 2 });
+    });
+
+    test("number keys", () => {
+      expect(
+        pullObject(["a", "aa"], (item) => item.length, identity),
+      ).toStrictEqual({
+        1: "a",
+        2: "aa",
+      });
+    });
+
+    test("undefined values", () => {
+      expect(pullObject(["a"], identity, constant(undefined))).toStrictEqual({
+        a: undefined,
+      });
+    });
+
+    test("last value wins", () => {
+      expect(
+        pullObject(
+          [
+            { id: "a", val: "hello" },
+            { id: "a", val: "world" },
+          ],
+          prop("id"),
+          prop("val"),
+        ),
+      ).toStrictEqual({ a: "world" });
+    });
+
+    test("Guaranteed to run on each item", () => {
+      const data = ["a", "a", "a", "a", "a", "a"];
+
+      const keyFn = vi.fn(identity);
+      const valueFn = vi.fn(identity);
+
+      pullObject(data, keyFn, valueFn);
+
+      expect(keyFn).toHaveBeenCalledTimes(data.length);
+      expect(valueFn).toHaveBeenCalledTimes(data.length);
+    });
+  });
+
+  describe("dataLast", () => {
+    test("empty array", () => {
+      expect(
+        pipe([], pullObject(constant("key"), constant("value"))),
+      ).toStrictEqual({});
+    });
+
+    test("number items", () => {
+      expect(
+        pipe(
+          [1, 2],
+          pullObject((item) => `item_${item}`, add(2)),
+        ),
+      ).toStrictEqual({ item_1: 3, item_2: 4 });
+    });
+
+    test("string items", () => {
+      expect(
+        pipe(
+          ["a", "b"],
+          pullObject(
+            (item) => `item_${item}`,
+            (item) => item.toUpperCase(),
+          ),
+        ),
+      ).toStrictEqual({ item_a: "A", item_b: "B" });
+    });
+
+    test("object items", () => {
+      expect(
+        pipe(
+          [
+            { key: "a", value: 1 },
+            { key: "b", value: 2 },
+          ],
+          pullObject(prop("key"), prop("value")),
+        ),
+      ).toStrictEqual({ a: 1, b: 2 });
+    });
+
+    test("number keys", () => {
+      expect(
+        pipe(
+          ["a", "aa"],
+          pullObject((item) => item.length, identity),
+        ),
+      ).toStrictEqual({ 1: "a", 2: "aa" });
+    });
+
+    test("undefined values", () => {
+      expect(
+        pipe(["a"], pullObject(identity, constant(undefined))),
+      ).toStrictEqual({
+        a: undefined,
+      });
+    });
+
+    test("last value wins", () => {
+      expect(
+        pipe(
+          [
+            { id: "a", val: "hello" },
+            { id: "a", val: "world" },
+          ],
+          pullObject(prop("id"), prop("val")),
+        ),
+      ).toStrictEqual({ a: "world" });
+    });
+
+    test("Guaranteed to run on each item", () => {
+      const data = ["a", "a", "a", "a", "a", "a"];
+
+      const keyFn = vi.fn(identity);
+      const valueFn = vi.fn(identity);
+
+      pipe(data, pullObject(keyFn, valueFn));
+
+      expect(keyFn).toHaveBeenCalledTimes(data.length);
+      expect(valueFn).toHaveBeenCalledTimes(data.length);
+    });
+  });
+});
+
+describe("typing", () => {
+  test("string keys", () => {
+    const data = ["a", "b"];
+
+    const dataFirst = pullObject(data, identity, constant("value"));
+    expectTypeOf(dataFirst).toEqualTypeOf<Partial<Record<string, string>>>();
+
+    const dataLast = pipe(data, pullObject(identity, constant("value")));
+    expectTypeOf(dataLast).toEqualTypeOf<Partial<Record<string, string>>>();
+  });
+
+  test("number keys", () => {
+    const data = [1, 2];
+
+    const dataFirst = pullObject(data, identity, constant(3));
+    expectTypeOf(dataFirst).toEqualTypeOf<Partial<Record<number, number>>>();
+
+    const dataLast = pipe(data, pullObject(identity, constant(3)));
+    expectTypeOf(dataLast).toEqualTypeOf<Partial<Record<number, number>>>();
+  });
+
+  test("symbol keys", () => {
+    const data = [Symbol("a"), Symbol("b")];
+
+    const dataFirst = pullObject(data, identity, constant(Symbol("c")));
+    expectTypeOf(dataFirst).toEqualTypeOf<Partial<Record<symbol, symbol>>>();
+
+    const dataLast = pipe(data, pullObject(identity, constant(Symbol("c"))));
+    expectTypeOf(dataLast).toEqualTypeOf<Partial<Record<symbol, symbol>>>();
+  });
+
+  test("number constants", () => {
+    const data = [1, 2] as const;
+
+    const dataFirst = pullObject(data, identity, constant(3 as const));
+    expectTypeOf(dataFirst).toEqualTypeOf<Partial<Record<1 | 2, 3>>>();
+
+    const dataLast = pipe(data, pullObject(identity, constant(3 as const)));
+    expectTypeOf(dataLast).toEqualTypeOf<Partial<Record<1 | 2, 3>>>();
+  });
+
+  test("string constants", () => {
+    const data = ["a", "b"] as const;
+
+    const dataFirst = pullObject(data, identity, constant("c" as const));
+    expectTypeOf(dataFirst).toEqualTypeOf<Partial<Record<"a" | "b", "c">>>();
+
+    const dataLast = pipe(data, pullObject(identity, constant("c" as const)));
+    expectTypeOf(dataLast).toEqualTypeOf<Partial<Record<"a" | "b", "c">>>();
+  });
+
+  test("literal unions keys", () => {
+    const data = [1, 2];
+
+    const dataFirst = pullObject(
+      data,
+      (item) => (item % 2 === 0 ? "odd" : "even"),
+      constant("c"),
+    );
+    expectTypeOf(dataFirst).toEqualTypeOf<
+      Partial<Record<"even" | "odd", string>>
+    >();
+
+    const dataLast = pipe(
+      data,
+      pullObject((item) => (item % 2 === 0 ? "odd" : "even"), constant("c")),
+    );
+    expectTypeOf(dataLast).toEqualTypeOf<
+      Partial<Record<"even" | "odd", string>>
+    >();
+  });
+
+  test("template string keys", () => {
+    const data = [1, 2];
+
+    const dataFirst = pullObject(
+      data,
+      (item) => `prefix_${item}`,
+      constant("value"),
+    );
+    expectTypeOf(dataFirst).toEqualTypeOf<
+      Partial<Record<`prefix_${number}`, string>>
+    >();
+
+    const dataLast = pipe(
+      data,
+      pullObject((item) => `prefix_${item}`, constant("value")),
+    );
+    expectTypeOf(dataLast).toEqualTypeOf<
+      Partial<Record<`prefix_${number}`, string>>
+    >();
+  });
+});