Repository: remeda/remeda (ts)

Issue:
indexBy return type widens object keys to `string` type
I'm currently using `indexBy` to construct an object that has its `id`s typed generically. Ideally, It would be nice if the return type of the function ensured that the object keys share the same type. Instead, the keys are always widened to `string`:

```ts
type Object<T extends string> = {
  id: T;
  property1: string;
}

const keyedObjects = indexBy([] as Object[], (i) => i.id)

typeof keyedObjects: Record<string, {
  id: T;
  property1: string;
}>
```

I think a signature similar to the below should allow for this to work properly. I'd be happy to open a PR, if its something people agree with.

```ts
export function indexBy<K extends PropertyKey, T>(
  array: ReadonlyArray<T>,
  fn: (item: T) => K
): Record<K, T>;
```

Pull request:
feat(indexBy): add strict variant of indexBy
Closes #398

`indexBy` currently widens all keys to a `string` type, ensuring that any extra type information is lost, and that other valid property keys (such numbers or symbols) can't be used.

This PR fixes this by adding a strict variant of the function which uses a new type parameter, `K`, tho maintains this type information and use it as the output record's key type.

---

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 `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!
- `fix`: changes to function implementations that fix a bug where a function produces the wrong _runtime_ results.
- `type`: type-only changes (params, overloading, return types, etc...)
- `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>


Patch:
diff --git a/src/indexBy.ts b/src/indexBy.ts
index e45fe46..5e8f47f 100644
--- a/src/indexBy.ts
+++ b/src/indexBy.ts
@@ -2,16 +2,21 @@ import { purry } from './purry';
 import { PredIndexedOptional, PredIndexed } from './_types';
 
 /**
- * Converts a list of objects into an object indexing the objects by the given key.
+ * 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`.
+ *
  * @param array the array
  * @param fn the indexing function
  * @signature
  *    R.indexBy(array, fn)
+ *    R.indexBy.strict(array, fn)
  * @example
- *    R.indexBy(['one', 'two', 'three'], x => x.length) // => {3: 'two', 5: 'three'}
+ *    R.indexBy(['one', 'two', 'three'], x => x.length) // => {"3": 'two', "5": 'three'}
+ *    R.indexBy.strict(['one', 'two', 'three'], x => x.length) // => {3: 'two', 5: 'three'}
  * @dataFirst
  * @indexed
  * @category Array
+ * @strict
  */
 export function indexBy<T>(
   array: ReadonlyArray<T>,
@@ -20,18 +25,27 @@ export function indexBy<T>(
 
 /**
  * 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`.
+ *
  * @param array the array
  * @param fn the indexing function
  * @signature
  *    R.indexBy(fn)(array)
+ *    R.indexBy.strict(fn)(array)
  * @example
  *    R.pipe(
  *      ['one', 'two', 'three'],
  *      R.indexBy(x => x.length)
+ *    ) // => {"3": 'two', "5": 'three'}
+ *    R.pipe(
+ *      ['one', 'two', 'three'],
+ *      R.indexBy.strict(x => x.length)
  *    ) // => {3: 'two', 5: 'three'}
  * @dataLast
  * @indexed
  * @category Array
+ * @strict
  */
 export function indexBy<T>(
   fn: (item: T) => any
@@ -52,6 +66,30 @@ const _indexBy =
     }, {});
   };
 
+function indexByStrict<K extends PropertyKey, T>(
+  array: ReadonlyArray<T>,
+  fn: (item: T) => K
+): Partial<Record<K, T>>;
+
+function indexByStrict<K extends PropertyKey, T>(
+  fn: (item: T) => K
+): (array: ReadonlyArray<T>) => Partial<Record<K, T>>;
+
+function indexByStrict() {
+  return purry(_indexByStrict, arguments);
+}
+
+const _indexByStrict = <K extends PropertyKey, T>(
+  array: ReadonlyArray<T>,
+  fn: (item: T) => K
+) => {
+  return array.reduce<Partial<Record<K, T>>>((ret, item) => {
+    const key = fn(item);
+    ret[key] = item;
+    return ret;
+  }, {});
+};
+
 export namespace indexBy {
   export function indexed<T>(
     array: ReadonlyArray<T>,
@@ -63,4 +101,5 @@ export namespace indexBy {
   export function indexed() {
     return purry(_indexBy(true), arguments);
   }
+  export const strict = indexByStrict;
 }


Test patch:
diff --git a/src/indexBy.test.ts b/src/indexBy.test.ts
index f1d0160..bd666f2 100644
--- a/src/indexBy.test.ts
+++ b/src/indexBy.test.ts
@@ -6,17 +6,30 @@ const array = [
   { dir: 'right', code: 100 },
 ] as const;
 const expected = {
-  left: { dir: 'left', code: 97 },
-  right: { dir: 'right', code: 100 },
+  '97': { dir: 'left', code: 97 },
+  '100': { dir: 'right', code: 100 },
 };
+const expectedStrict = {
+  97: { dir: 'left', code: 97 },
+  100: { dir: 'right', code: 100 },
+};
+type ExpectedTypeStrict = Partial<
+  Record<97 | 100, { dir: 'left' | 'right'; code: 97 | 100 }>
+>;
 
 describe('data first', () => {
   test('indexBy', () => {
-    expect(indexBy(array, x => x.dir)).toEqual(expected);
+    expect(indexBy(array, x => x.code)).toEqual(expected);
   });
 
   test('indexBy.indexed', () => {
-    expect(indexBy.indexed(array, x => x.dir)).toEqual(expected);
+    expect(indexBy.indexed(array, x => x.code)).toEqual(expected);
+  });
+
+  test('indexBy.strict', () => {
+    const result = indexBy.strict(array, x => x.code);
+    expectTypeOf<ExpectedTypeStrict>(result);
+    expect(result).toStrictEqual(expectedStrict);
   });
 });
 
@@ -25,7 +38,7 @@ describe('data last', () => {
     expect(
       pipe(
         array,
-        indexBy(x => x.dir)
+        indexBy(x => x.code)
       )
     ).toEqual(expected);
   });
@@ -34,8 +47,17 @@ describe('data last', () => {
     expect(
       pipe(
         array,
-        indexBy.indexed(x => x.dir)
+        indexBy.indexed(x => x.code)
       )
     ).toEqual(expected);
   });
+
+  test('indexBy.strict', () => {
+    const result = pipe(
+      array,
+      indexBy.strict(x => x.code)
+    );
+    expectTypeOf<ExpectedTypeStrict>(result);
+    expect(result).toEqual(expectedStrict);
+  });
 });