I have a method called groupBy
(adapted from this gist) that groups an array of objects based on unique key values given an array of key names. It returns an object of shape Record<string, T[]>
by default, where T[]
is a sub-array of the input objects. If the boolean parameter indexes
is true
, it instead returns Record<string, number[]>
, where number[]
corresponds to indexes of the input array instead of the values themselves.
I'm able to use conditional typing in the function signature to indicate that the return type changes based on 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'])
When using the function, the two constants declared at the end have the appropriate typing, but I see the following error if I remove 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.
I think I understand that this is because the conditional type isn't evaluated inside the method, so TypeScript doesn't know that the array won't have mixed types. I can resolve the error by using a larger if-else block, e.g.:
if (indexes) {
return ... as Record<string, number[]>
} else {
return ... as Record<string, T[]>
}
But this requires copying the entire function in both branches with just a slight difference. Is there a more clever way to use conditional types within the function so it's not necessary to duplicate the method with just a slight difference in each branch?
Implementations of overloaded functions are intentionally checked more loosely than the set of call signatures. See microsoft/TypeScript#13235 for more information. That means it isn't necessarily worth trying to convince the compiler that what you're doing is correct, since if you fail to do it properly, it will still probably not notice your mistake. With that in mind, the general approach to overload implementations is to double and triple check that your logic is correct (since the compiler can't) and then loosen the types up enough so that this correct implementation doesn't have compiler errors.
For your example code I'd probably just return
Record<string, (T | number)[]>
from the implementation, like this:Here I've manually specified the generic type parameter to
reduce()
asRecord<string, (T | number)[]>
which is slightly more type safe than writing{} as Record<string, (T | number)[]>
(but that would also be fine), which lets the compiler know that the return value isRecord<string, (T | number)[]>
, which is accepted according to the loose overload rules.So now there are no compiler errors. Again, this doesn't mean the code is safe; you could change
.concat(indexes ? index : obj)
to.concat(!indexes ? index : obj)
and still have no compiler errors. So do be careful with overloads and take care that your algorithm works how you think it does.Playground link to code