Tout d'abord, il est important de déterminer la relation de type entrée/sortie que vous voulez pour multiGroupBy()
. Il existe un spectre de relations possibles ; à une extrémité de ce spectre, on trouve des typages simples qui ne sont pas d'une grande utilité, comme ceux où, quoi que vous passiez dans multiGroupBy()
le type qui sort est object
. De l'autre côté, vous avez des typages incroyablement compliqués qui peuvent potentiellement représenter la valeur exacte qui sort, en fonction de ce qui entre... imaginez une situation dans laquelle vous appelez multiGroupBy([{x: "a", y: "b"}, {x: "a", y: "c"}], ["x", "y"])
et la valeur de retour a la valeur type {a: {b: [{x: "a", y: "b"}], c: [{x: "a", y: "c"}]}}
. Ce typage pourrait potentiellement être facile à utiliser, mais au prix d'être assez difficile à mettre en œuvre, et éventuellement fragile.
Nous devons donc trouver un équilibre entre ces deux objectifs. Le typage le plus simple qui semble utile ressemblerait à ceci :
type MultiGroupBy<T> = (T[]) | { [k: string]: MultiGroupBy<T> };
declare const multiGroupBy: <O extends object>(
array: O[],
groups: (keyof O)[]
) => MultiGroupBy<O>;
Si vous saisissez un array
de type O[]
y groups
de type Array<keyof O>
alors la sortie est du type MultiGroupBy<O>
. Ici, nous représentons la sortie comme un syndicat soit un tableau des types d'entrée, soit un objet de type dictionnaire de tableaux ou de dictionnaires. La définition est infiniment récursive, et n'a pas de moyen de spécifier la profondeur de l'objet. Pour l'utiliser, il faudrait tester chaque niveau.
De plus, lorsque la sortie est un dictionnaire, le compilateur n'a aucune idée de ce que seront les clés de ce dictionnaire. Il s'agit d'une limitation, et il existe des moyens d'y remédier, mais cela rendrait les choses assez compliquées et puisque vous êtes d'accord pour avoir des clés inconnues, je ne m'y attarderai pas.
Explorons donc un typage qui garde la trace de la profondeur de la structure de sortie :
type MultiGroupBy<T, K extends any[] = any[]> =
number extends K['length'] ? (T[] | { [k: string]: MultiGroupBy<T, K> }) :
K extends [infer F, ...infer R] ? { [k: string]: MultiGroupBy<T, R> } :
T[];
declare const multiGroupBy: <O extends object, K extends Array<keyof O>>(
array: O[],
groups: [...K]
) => MultiGroupBy<O, K>;
Maintenant multiGroupBy()
prend un array
de type O[]
et un groups
de type K
donde K
es contraint pour qu'il puisse être cédé à Array<keyof O>
. Et le type de sortie, MultiGroupBy<T, K>
, utilise types conditionnels pour déterminer quel sera le type de sortie. Brièvement :
Si K
est un tableau de longueur inconnue, alors le compilateur produira quelque chose de très similaire à l'ancienne définition de MultiGroupBy<T>
une union de tableaux ou de dictionnaires imbriqués ; c'est vraiment le mieux que vous puissiez faire si vous ne connaissez pas la longueur du tableau au moment de la compilation. Sinon, le compilateur essaie de voir si le K
est un tuple qui peut être divisé en son premier élément F
et un tuple du reste des éléments R
. Si c'est le cas, alors le type de sortie est un dictionnaire, dont les valeurs sont du type MultiGroupBy<T, R>
... c'est une étape récursive, et à chaque fois que vous passez par la récursion, le tuple devient plus court d'un élément. Si, d'un autre côté, le compilateur ne peut pas diviser K
en un premier et un dernier, alors il est vide... et dans ce cas, le type de sortie est le T[]
le tableau.
Cette saisie semble donc assez proche de ce que nous voulons.
Nous n'avons pas tout à fait fini, cependant. La saisie ci-dessus permet aux touches de groups
à être tout à partir des éléments de array
y compris celles où la valeur de la propriété n'est pas une chaîne de caractères :
const newArray = [{ foo: { a: 123 }, bar: 'hey' }];
const errors = multiGroupBy(newArray, ['foo']); // accepted?!
Vous ne voulez pas permettre ça. Donc nous devons faire le typage de multiGroupBy()
un peu plus compliqué :
type KeysOfPropsWithStringValues<T> =
keyof T extends infer K ? K extends keyof T ?
T[K] extends string ? K : never
: never : never;
declare const multiGroupBy:
<O extends object, K extends KeysOfPropsWithStringValues<O>[]>(
array: O[], groups: [...K]) => MultiGroupBy<O, K>;
Le type KeysOfPropsWithStringValues<T>
utilise des types conditionnels pour trouver toutes les clés K
de T
où T[K]
est cessible à string
. Il s'agit d'un sous-type de keyof T
. Vous pouvez écrire cela d'une autre manière, par exemple en termes de KeysMatching
de cette réponse mais c'est la même chose.
Et ensuite nous contraignons K
a Array<KeysOfPropsWithStringValues<O>>
au lieu de Array<keyof O>
. Ce qui va fonctionner maintenant :
const errors = multiGroupBy(newArray, ['foo']); // error!
// ----------------------------------> ~~~~~
// Type '"foo"' is not assignable to type '"bar"'.
Juste pour être sûr que nous sommes satisfaits de ces typages, regardons comment le compilateur voit un exemple d'utilisation :
interface ObjectType {
category: string;
subCategory: string;
item: string;
}
declare const arrayOfObjects: ObjectType[];
const orderedGroupRows = multiGroupBy(arrayOfObjects,
['category', 'subCategory' ]);
/* const orderedGroupRows: {
[k: string]: {
[k: string]: ObjectType[];
};
} */
Ça a l'air bien ! Le compilateur voit orderedGroupRows
comme un dictionnaire de dictionnaires de tableaux de ObjectType
.
Enfin, la mise en œuvre. Il s'avère plus ou moins impossible d'implémenter une fonction générique renvoyant un type conditionnel sans utiliser quelque chose comme assertions de type o any
. Voir microsoft/TypeScript#33912 pour plus d'informations. Voici donc ce que je peux faire de mieux sans remanier votre code (et même si je le remaniais, il ne s'améliorerait pas beaucoup) :
const multiGroupBy = <
O extends object,
K extends Array<KeysOfPropsWithStringValues<O>>
>(
array: O[],
[group, ...restGroups]: [...K]
): MultiGroupBy<O, K> => {
if (!group) {
return array as MultiGroupBy<O, K>; // assert
}
const currGrouping = groupBy(array, group);
if (!restGroups.length) {
return currGrouping as MultiGroupBy<O, K>; // assert
}
return transform(
currGrouping,
(result, value, key) => {
result[key] = multiGroupBy(value, [...restGroups]);
},
{} as any // give up and use any
);
};
Si nous avions utilisé le typage original des unions, l'implémentation aurait été plus facile à vérifier par le compilateur. Mais comme nous utilisons des types conditionnels génériques, nous n'avons pas cette chance.
Mais dans tous les cas, je ne m'inquiéterais pas trop du fait que l'implémentation ait besoin d'assertions. L'intérêt de ces typages est que appelants de multiGroupBy()
bénéficie de solides garanties de type ; il ne doit être implémenté qu'une seule fois, et vous pouvez vous assurer que vous le faites correctement puisque le compilateur n'est pas équipé pour le faire à votre place.
Lien vers le code de Stackblitz