In my codebase, I've implemented a function named groupBy
(inspired by this gist) that groups an array of objects based on unique key values provided an array of key names. By default, it returns an object structured as Record<string, T[]>
, where T[]
represents a subset of the input objects. However, if the optional boolean parameter indexes
is set to true
, it will instead produce a result in the form of Record<string, number[]>
, with number[]
denoting indexes from the original array rather than the actual values.
The beauty of this implementation lies in its utilization of conditional typing within the function signature, allowing for dynamic changes in the return type based on the state of the indexes
parameter:
/**
* @description Group an array of objects based on unique key values given an array of key names.
* @param {[Object]} array
* @param {[string]} keys
* @param {boolean} [indexes] Set true to return indexes from the original array instead of values
* @return {Object.<string, []>} e.g. {'key1Value1-key2Value1': [obj, obj], 'key1Value2-key2Value1: [obj]}
*/
function groupBy<T>(array: T[], keys: (keyof T)[], indexes?: false): Record<string, T[]>;
function groupBy<T>(array: T[], keys: (keyof T)[], indexes?: true): Record<string, number[]>;
function groupBy<T>(
array: T[],
keys: (keyof T)[],
indexes = false,
): Record<string, T[]> | Record<string, number[]> {
return array.reduce((objectsByKeyValue, obj, index) => {
const value = keys.map((key) => obj[key]).join('-');
// @ts-ignore
objectsByKeyValue[value] = (objectsByKeyValue[value] || []).concat(indexes ? index : obj);
return objectsByKeyValue;
}, {} as Record<string, T[]> | Record<string, number[]>);
}
const foo = groupBy([{'hey': 1}], ['hey'], true)
const bar = groupBy([{'hey': 1}], ['hey'])
While using the function, I encountered an error message when removing the // @ts-ignore
:
TS2349: This expression is not callable.
Each member of the union type
'{ (...items: ConcatArray<number>[]): number[]; (...items: (number | ConcatArray<number>)[]): number[]; } |
{ (...items: ConcatArray<T>[]): T[]; (...items: (T | ConcatArray<...>)[]): T[]; }'
has signatures, but none of those signatures are compatible with each other.
This issue stems from TypeScript's inability to evaluate the conditional types internally within the function, leading to potential confusion regarding mixed types in the resulting array. To circumvent this problem, one solution involves employing a larger if-else block like so:
if (indexes) {
return ... as Record<string, number[]>
} else {
return ... as Record<string, T[]>
}
Unfortunately, implementing this approach necessitates duplicating the entire function with only minor variations in each branch. Is there a more elegant strategy to leverage conditional types within the function, obviating the need for duplicate code segments with minimal discrepancies?