diff --git a/src/array/group.ts b/src/array/group.ts index 8a694ab5..aa4d1b5d 100644 --- a/src/array/group.ts +++ b/src/array/group.ts @@ -6,7 +6,7 @@ export const group = ( array: readonly T[], getGroupId: (item: T) => Key -): Partial> => { +): { [K in Key]?: T[] } => { return array.reduce((acc, item) => { const groupId = getGroupId(item) if (!acc[groupId]) acc[groupId] = [] diff --git a/src/array/tests/group.test.ts b/src/array/tests/group.test.ts index 9b7e9fd6..caeb8e97 100644 --- a/src/array/tests/group.test.ts +++ b/src/array/tests/group.test.ts @@ -15,4 +15,29 @@ describe('group function', () => { expect(groups.c?.length).toBe(1) expect(groups.c?.[0].word).toBe('ok') }) + test('works with mapValues', () => { + const objects = [ + { id: 1, group: 'a' }, + { id: 2, group: 'b' }, + { id: 3, group: 'a' } + ] as const + + // Notice how the types of `groupedObjects` and `groupedIds` are + // both partial (in other words, their properties have the `?:` + // modifier). At the type level, mapValues is respectful of + // preserving the partiality of the input. + const groupedObjects = _.group(objects, obj => obj.group) + const groupedIds = _.mapValues(groupedObjects, array => { + // Importantly, we can map the array without optional chaining, + // because of how the overloads of mapValues are defined. + // TypeScript knows that when a key is defined inside the result + // of a `group(…)` call, its value is never undefined. + return array.map(obj => obj.id) + }) + + expect(groupedIds).toEqual({ + a: [1, 3], + b: [2] + }) + }) }) diff --git a/src/object/mapValues.ts b/src/object/mapValues.ts index a5b228b7..f796c26c 100644 --- a/src/object/mapValues.ts +++ b/src/object/mapValues.ts @@ -1,17 +1,39 @@ /** * Map over all the keys to create a new object */ -export const mapValues = < +export function mapValues< TValue, TKey extends string | number | symbol, TNewValue >( - obj: Record, + obj: { [K in TKey]: TValue }, mapFunc: (value: TValue, key: TKey) => TNewValue -): Record => { +): { [K in TKey]: TNewValue } + +// This overload exists to support cases where `obj` is a partial +// object whose values are never undefined when the key is also +// defined. For example: +// { [key: string]?: number } versus { [key: string]: number | undefined } +export function mapValues< + TValue, + TKey extends string | number | symbol, + TNewValue +>( + obj: { [K in TKey]?: TValue }, + mapFunc: (value: TValue, key: TKey) => TNewValue +): { [K in TKey]?: TNewValue } + +export function mapValues< + TValue, + TKey extends string | number | symbol, + TNewValue +>( + obj: { [K in TKey]?: TValue }, + mapFunc: (value: TValue, key: TKey) => TNewValue +): Record { const keys = Object.keys(obj) as TKey[] return keys.reduce((acc, key) => { - acc[key] = mapFunc(obj[key], key) + acc[key] = mapFunc(obj[key]!, key) return acc }, {} as Record) } diff --git a/src/object/tests/mapValues.test.ts b/src/object/tests/mapValues.test.ts index a364da6c..e02eaa89 100644 --- a/src/object/tests/mapValues.test.ts +++ b/src/object/tests/mapValues.test.ts @@ -15,4 +15,15 @@ describe('mapValues function', () => { y: 'xbye' }) }) + test('objects with possibly undefined values', () => { + const result = _.mapValues({ x: 'hi ', y: undefined }, value => { + // Importantly, the value is typed as "string | undefined" + // here, due to how the overloads of mapValues are defined. + return value?.trim() + }) + expect(result).toEqual({ + x: 'hi', + y: undefined + }) + }) })