Repository: remeda/remeda
Pull request #575 (2024-03-17 21:14:38)

feat: fromKeys
Create an object from an array of keys. A simpler API surface than `mapToObj` with better typing.

---

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

Latest deployment of this branch, based on commit 1aeaf79b065d6cd6f69f2e0cb014f457850e77e2:

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


Patch:
diff --git a/src/fromKeys.ts b/src/fromKeys.ts
new file mode 100644
index 0000000..e386f5a
--- /dev/null
+++ b/src/fromKeys.ts
@@ -0,0 +1,76 @@
+import type { IterableContainer } from "./_types";
+import { purry } from "./purry";
+import type { Simplify } from "./type-fest/simplify";
+
+// Takes a union of literals and creates a union of records with the value V for
+// each key **separately**
+// @example ExactlyOneKey<"cat" | "dog", boolean> // { cat: boolean } | { dog: boolean }
+type ExactlyOneKey<T, V> = T extends PropertyKey ? { [P in T]: V } : never;
+
+type FromKeys<T extends IterableContainer, V> = T extends readonly []
+  ? // eslint-disable-next-line @typescript-eslint/ban-types -- We want to return an empty object type here, but it's not trivial to build that in Typescript, other fixer suggestions like Record<PropertyKey, never> or Record<PropertyKey, unknown> both break our type tests so they don't do what we need here. Because the result is mutable this might be the correct type after all...
+    {}
+  : T extends readonly [infer Head, ...infer Rest]
+    ? ExactlyOneKey<Head, V> & FromKeys<Rest, V>
+    : T[number] extends PropertyKey
+      ? Partial<Record<T[number], V>>
+      : never;
+
+/**
+ * Creates an object that maps each key in `data` to the result of `mapper` for
+ * that key. Duplicate keys are overwritten, guaranteeing that `mapper` is run
+ * for each item in `data`.
+ *
+ * @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
+ * with that key.
+ * @signature
+ *   R.fromKeys(data, mapper);
+ * @example
+ *   R.fromKeys(["cat", "dog"], R.length()); // { cat: 3, dog: 3 } (typed as Partial<Record<"cat" | "dog", number>>)
+ *   R.fromKeys([1, 2], R.add(1)); // { 1: 2, 2: 3 } (typed as Partial<Record<1 | 2, number>>)
+ * @dataFirst
+ * @category Object
+ */
+export function fromKeys<T extends IterableContainer<PropertyKey>, V>(
+  data: T,
+  mapper: (item: T[number]) => V,
+): Simplify<FromKeys<T, V>>;
+
+/**
+ * Creates an object that maps each key in `data` to the result of `mapper` for
+ * that key. Duplicate keys are overwritten, guaranteeing that `mapper` is run
+ * for each item in `data`.
+ *
+ * @param mapper - Takes a key and returns the value that would be associated
+ * with that key.
+ * @signature
+ *   R.fromKeys(mapper)(data);
+ * @example
+ *   R.pipe(["cat", "dog"], R.fromKeys(R.length())); // { cat: 3, dog: 3 } (typed as Partial<Record<"cat" | "dog", number>>)
+ *   R.pipe([1, 2], R.fromKeys(R.add(1))); // { 1: 2, 2: 3 } (typed as Partial<Record<1 | 2, number>>)
+ * @dataLast
+ * @category Object
+ */
+export function fromKeys<T extends IterableContainer<PropertyKey>, V>(
+  mapper: (item: T[number]) => V,
+): (data: T) => Simplify<FromKeys<T, V>>;
+
+export function fromKeys(): unknown {
+  return purry(fromKeysImplementation, arguments);
+}
+
+function fromKeysImplementation<T extends IterableContainer<PropertyKey>, V>(
+  data: T,
+  mapper: (item: T[number]) => V,
+): FromKeys<T, V> {
+  const result: Partial<FromKeys<T, V>> = {};
+
+  for (const key of data) {
+    // @ts-expect-error [ts7053] - There's no easy way to make Typescript aware that the items in T would be keys in the output object because it's type is built recursively and the "being an item of an array" property of a type is not "carried over" in the recursive type definition.
+    result[key] = mapper(key);
+  }
+
+  return result as FromKeys<T, V>;
+}
diff --git a/src/index.ts b/src/index.ts
index 3a76d0b..b81da1e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -38,6 +38,7 @@ export * from "./flattenDeep";
 export * from "./floor";
 export * from "./forEach";
 export * from "./forEachObj";
+export * from "./fromKeys";
 export * from "./fromPairs";
 export * from "./groupBy";
 export * from "./hasAtLeast";


Test patch:
diff --git a/src/fromKeys.test.ts b/src/fromKeys.test.ts
new file mode 100644
index 0000000..bb28287
--- /dev/null
+++ b/src/fromKeys.test.ts
@@ -0,0 +1,368 @@
+import { add } from "./add";
+import { fromKeys } from "./fromKeys";
+import { pipe } from "./pipe";
+import type { Simplify } from "./type-fest/simplify";
+
+type Letter =
+  | "a"
+  | "b"
+  | "c"
+  | "d"
+  | "e"
+  | "f"
+  | "g"
+  | "h"
+  | "i"
+  | "j"
+  | "k"
+  | "l"
+  | "m"
+  | "n"
+  | "o"
+  | "p"
+  | "q"
+  | "r"
+  | "s"
+  | "t"
+  | "u"
+  | "v"
+  | "w"
+  | "x"
+  | "y"
+  | "z";
+
+describe("runtime", () => {
+  it("works on trivially empty arrays", () => {
+    expect(fromKeys([] as Array<string>, (item) => `${item}_`)).toEqual({});
+  });
+
+  it("works on regular arrays", () => {
+    expect(fromKeys(["a"], (item) => `${item}_`)).toEqual({ a: "a_" });
+  });
+
+  it("works with duplicates", () => {
+    expect(fromKeys(["a", "a"], (item) => `${item}_`)).toEqual({ a: "a_" });
+  });
+
+  it("uses the last value", () => {
+    let counter = 0;
+    expect(
+      fromKeys(["a", "a"], () => {
+        counter += 1;
+        return counter;
+      }),
+    ).toEqual({ a: 2 });
+  });
+
+  it("works with number keys", () => {
+    expect(fromKeys([123], add(1))).toEqual({ 123: 124 });
+  });
+
+  it("works with symbols", () => {
+    const symbol = Symbol("a");
+    expect(fromKeys([symbol], () => 1)).toEqual({ [symbol]: 1 });
+  });
+
+  it("works with a mix of key types", () => {
+    const symbol = Symbol("a");
+    expect(fromKeys(["a", 123, symbol], (item) => typeof item)).toEqual({
+      a: "string",
+      123: "number",
+      [symbol]: "symbol",
+    });
+  });
+});
+
+describe("dataLast", () => {
+  it("works on trivially empty arrays", () => {
+    expect(
+      pipe(
+        [] as Array<string>,
+        fromKeys((item) => `${item}_`),
+      ),
+    ).toEqual({});
+  });
+
+  it("works on regular arrays", () => {
+    expect(
+      pipe(
+        ["a"],
+        fromKeys((item) => `${item}_`),
+      ),
+    ).toEqual({ a: "a_" });
+  });
+
+  it("works with duplicates", () => {
+    expect(
+      pipe(
+        ["a", "a"],
+        fromKeys((item) => `${item}_`),
+      ),
+    ).toEqual({ a: "a_" });
+  });
+
+  it("uses the last value", () => {
+    let counter = 0;
+    expect(
+      pipe(
+        ["a", "a"],
+        fromKeys(() => {
+          counter += 1;
+          return counter;
+        }),
+      ),
+    ).toEqual({ a: 2 });
+  });
+
+  it("works with number keys", () => {
+    expect(pipe([123], fromKeys(add(1)))).toEqual({ 123: 124 });
+  });
+
+  it("works with symbols", () => {
+    const symbol = Symbol("a");
+    expect(
+      pipe(
+        [symbol],
+        fromKeys(() => 1),
+      ),
+    ).toEqual({ [symbol]: 1 });
+  });
+
+  it("works with a mix of key types", () => {
+    const symbol = Symbol("a");
+    expect(
+      pipe(
+        ["a", 123, symbol],
+        fromKeys((item) => typeof item),
+      ),
+    ).toEqual({
+      a: "string",
+      123: "number",
+      [symbol]: "symbol",
+    });
+  });
+});
+
+describe("typing", () => {
+  test("empty array", () => {
+    const data = [] as const;
+
+    const dataFirst = fromKeys(data, getVal);
+    // eslint-disable-next-line @typescript-eslint/ban-types -- That's just what we return
+    expectTypeOf(dataFirst).toEqualTypeOf<{}>();
+
+    const dataLast = pipe(data, fromKeys(getVal));
+    // eslint-disable-next-line @typescript-eslint/ban-types -- That's just what we return
+    expectTypeOf(dataLast).toEqualTypeOf<{}>();
+  });
+
+  test("fixed tuple", () => {
+    const data = ["cat", "dog"] as const;
+
+    const dataFirst = fromKeys(data, getVal);
+    expectTypeOf(dataFirst).toEqualTypeOf<Record<"cat" | "dog", number>>();
+
+    const dataLast = pipe(data, fromKeys(getVal));
+    expectTypeOf(dataLast).toEqualTypeOf<Record<"cat" | "dog", number>>();
+  });
+
+  describe("with simple keys", () => {
+    test("regular array", () => {
+      const data = [] as Array<string>;
+
+      const dataFirst = fromKeys(data, getVal);
+      expectTypeOf(dataFirst).toEqualTypeOf<Partial<Record<string, number>>>();
+
+      const dataLast = pipe(data, fromKeys(getVal));
+      expectTypeOf(dataLast).toEqualTypeOf<Partial<Record<string, number>>>();
+    });
+
+    test("non-empty array", () => {
+      const data = ["cat"] as [string, ...Array<string>];
+
+      const dataFirst = fromKeys(data, getVal);
+      expectTypeOf(dataFirst).toEqualTypeOf<Record<string, number>>();
+
+      const dataLast = pipe(data, fromKeys(getVal));
+      expectTypeOf(dataLast).toEqualTypeOf<Record<string, number>>();
+    });
+
+    test("fixed tuple", () => {
+      const data = ["cat", "dog"] as [string, string];
+
+      const dataFirst = fromKeys(data, getVal);
+      expectTypeOf(dataFirst).toEqualTypeOf<Record<string, number>>();
+
+      const dataLast = pipe(data, fromKeys(getVal));
+      expectTypeOf(dataLast).toEqualTypeOf<Record<string, number>>();
+    });
+  });
+
+  describe("with literal union keys", () => {
+    test("regular array", () => {
+      const data = [] as Array<"cat" | "dog">;
+
+      const dataFirst = fromKeys(data, getVal);
+      expectTypeOf(dataFirst).toEqualTypeOf<
+        Partial<Record<"cat" | "dog", number>>
+      >();
+
+      const dataLast = pipe(data, fromKeys(getVal));
+      expectTypeOf(dataLast).toEqualTypeOf<
+        Partial<Record<"cat" | "dog", number>>
+      >();
+    });
+
+    test("non-empty array", () => {
+      const data = ["cat"] as ["cat" | "dog", ...Array<"mouse" | "pig">];
+
+      const dataFirst = fromKeys(data, getVal);
+      expectTypeOf(dataFirst).toEqualTypeOf<
+        Simplify<
+          Partial<Record<"mouse" | "pig", number>> &
+            ({ cat: number } | { dog: number })
+        >
+      >();
+
+      const dataLast = pipe(data, fromKeys(getVal));
+      expectTypeOf(dataLast).toEqualTypeOf<
+        Simplify<
+          Partial<Record<"mouse" | "pig", number>> &
+            ({ cat: number } | { dog: number })
+        >
+      >();
+    });
+
+    test("fixed tuple", () => {
+      const data = ["cat", "mouse"] as ["cat" | "dog", "mouse" | "pig"];
+
+      const dataFirst = fromKeys(data, getVal);
+      expectTypeOf(dataFirst).toEqualTypeOf<
+        Simplify<
+          ({ cat: number } | { dog: number }) &
+            ({ mouse: number } | { pig: number })
+        >
+      >();
+
+      const dataLast = pipe(data, fromKeys(getVal));
+      expectTypeOf(dataLast).toEqualTypeOf<
+        Simplify<
+          ({ cat: number } | { dog: number }) &
+            ({ mouse: number } | { pig: number })
+        >
+      >();
+    });
+  });
+
+  describe("with string template keys", () => {
+    test("regular array", () => {
+      const data = [] as Array<`prefix_${number}`>;
+
+      const dataFirst = fromKeys(data, getVal);
+      expectTypeOf(dataFirst).toEqualTypeOf<
+        Partial<Record<`prefix_${number}`, number>>
+      >();
+
+      const dataLast = pipe(data, fromKeys(getVal));
+      expectTypeOf(dataLast).toEqualTypeOf<
+        Partial<Record<`prefix_${number}`, number>>
+      >();
+    });
+
+    test("non-empty array", () => {
+      const data = ["prefix_1"] as [
+        `prefix_${number}`,
+        ...Array<`prefix_${number}`>,
+      ];
+
+      const dataFirst = fromKeys(data, getVal);
+      expectTypeOf(dataFirst).toEqualTypeOf<
+        Record<`prefix_${number}`, number>
+      >();
+
+      const dataLast = pipe(data, fromKeys(getVal));
+      expectTypeOf(dataLast).toEqualTypeOf<
+        Record<`prefix_${number}`, number>
+      >();
+    });
+
+    test("fixed tuple", () => {
+      const data = ["prefix_1", "2_suffix"] as [
+        `prefix_${number}`,
+        `${number}_suffix`,
+      ];
+
+      const dataFirst = fromKeys(data, getVal);
+      expectTypeOf(dataFirst).toEqualTypeOf<
+        Record<`${number}_suffix` | `prefix_${number}`, number>
+      >();
+
+      const dataLast = pipe(data, fromKeys(getVal));
+      expectTypeOf(dataLast).toEqualTypeOf<
+        Record<`${number}_suffix` | `prefix_${number}`, number>
+      >();
+    });
+
+    describe("number keys", () => {
+      test("regular array", () => {
+        const data = [] as Array<number>;
+
+        const dataFirst = fromKeys(data, getVal);
+        expectTypeOf(dataFirst).toEqualTypeOf<
+          Partial<Record<number, number>>
+        >();
+
+        const dataLast = pipe(data, fromKeys(getVal));
+        expectTypeOf(dataLast).toEqualTypeOf<Partial<Record<number, number>>>();
+      });
+
+      test("non-empty array", () => {
+        const data = [1] as [number, ...Array<number>];
+
+        const dataFirst = fromKeys(data, getVal);
+        expectTypeOf(dataFirst).toEqualTypeOf<Record<number, number>>();
+
+        const dataLast = pipe(data, fromKeys(getVal));
+        expectTypeOf(dataLast).toEqualTypeOf<Record<number, number>>();
+      });
+
+      test("fixed tuple", () => {
+        const data = [1, 2] as [number, number];
+
+        const dataFirst = fromKeys(data, getVal);
+        expectTypeOf(dataFirst).toEqualTypeOf<Record<number, number>>();
+
+        const dataLast = pipe(data, fromKeys(getVal));
+        expectTypeOf(dataLast).toEqualTypeOf<Record<number, number>>();
+      });
+
+      test("literals", () => {
+        const data = [1, 2, 3] as const;
+
+        const dataFirst = fromKeys(data, getVal);
+        expectTypeOf(dataFirst).toEqualTypeOf<Record<1 | 2 | 3, number>>();
+
+        const dataLast = pipe(data, fromKeys(getVal));
+        expectTypeOf(dataLast).toEqualTypeOf<Record<1 | 2 | 3, number>>();
+      });
+    });
+  });
+
+  test("typescript doesn't choke on huge literal unions", () => {
+    const data = [] as Array<`${Letter}${Letter}`>;
+
+    const dataFirst = fromKeys(data, getVal);
+    expectTypeOf(dataFirst).toEqualTypeOf<
+      Partial<Record<`${Letter}${Letter}`, number>>
+    >();
+
+    const dataLast = pipe(data, fromKeys(getVal));
+    expectTypeOf(dataLast).toEqualTypeOf<
+      Partial<Record<`${Letter}${Letter}`, number>>
+    >();
+  });
+});
+
+function getVal(): number {
+  return 1;
+}