2

I have a function that takes 2 arguments 1: an array of objects and 2: an array of strings that are keys on the object before

and creates grouping according to them. How do I type for the result object value any in the following recursive function?

import { groupBy, transform } from 'lodash'

const multiGroupBy = (array: objectType[], [group, ...restGroups]: string[]) => {
    if (!group) {
      return array
    }
    const currGrouping = groupBy(array, group)

    if (!restGroups.length) {
      return currGrouping
    }
    return transform(
      currGrouping,
      (result: { [key: string]: any }, value, key) => {
        // What do I type as the any value above
        result[key] = multiGroupBy(value, [...restGroups])
      },
      {}
    )
  }

  const orderedGroupRows = multiGroupBy(arrayOfObjects, ['category', 'subCategory'])

So the result will be a recursive structure like this depending on the length of array of strings

const result = {
  "Category Heading 1": {
    "SubCategory Heading 1": [
      ...arrayofOfObjects
    ]
  },
  "Category Heading 2": {
    "SubCategory Heading 2": [
      ...arrayofOfObjects
    ]
  }
}

Here is a codesandbox for the code.

Newbie21
  • 123
  • 11
  • 1
    Please consider modifying the code in this question so as to constitute a [mcve] which, when dropped into a standalone IDE like [The TypeScript Playground (link)](https://tsplay.dev/w6BvGw), clearly demonstrates the issue you are facing, without unrelated errors, extra code, or missing declarations. This will allow those who want to help you to immediately get to work solving the problem without first needing to re-create it. And it will make it so that any answer you get is testable against a well-defined use case. – jcalz Aug 04 '21 at 17:38
  • You still haven't defined `objectType`, and I'm not sure if you need strongly typed keys in your output (should the *compiler* know that, for example, `"Category Heading 1"` is a key of `result`? Or is it okay if it just knows that it has some `string` keys without knowing what they are?) – jcalz Aug 04 '21 at 18:21
  • Depending on how much info you need in the typings, you could do [this](https://tsplay.dev/WPjpKN). If that works for you I can write up an answer; if not, please elaborate on what is missing. – jcalz Aug 04 '21 at 18:56
  • Thanks a lot for typing that out it looks a bit complicated. I have added more info with a codesandbox link above with typing for lodash library you can take a look for more details. Can you check and see if simpler typing is available? – Newbie21 Aug 05 '21 at 06:49
  • "it looks a bit complicated" yes, if you need a strongly-typed output then the typing is somewhat complicated. Can you explain what return type you want to see coming out of `multiGroupBy(arrayOfObjects, ["category", "subCategory"])`? You don't want `any`, or something trivial like `object`. The next simplest would be `type Ret = Record`. After that you could have `Record`, and after that you start having the compiler track the particular keys like `{"Category Heading 1": {"SubCategory Heading 1": ObjectType[]}, ...}`. – jcalz Aug 05 '21 at 13:30
  • In the interest of not writing an answer that goes through every type one could possibly want coming out of `multiGroupBy()`, I'd prefer if you would pick one and then I could focus on answering that. – jcalz Aug 05 '21 at 13:31
  • Additionally; I assume you want `multiGroupBy` to be *generic* in the type of the elements of the `array` parameter. Do you want `[group, ...restGroups]` elements to be restricted to only those keys which have key-like values? I mean, do you want `multiGroupBy([{foo: new Date()}], ["foo"])` to be accepted even though the `foo` property does not have values that can be used as keys? – jcalz Aug 05 '21 at 13:35
  • [Here](https://stackblitz.com/edit/typescript-53yvmi?file=index.ts) is the simplest typing I can imagine which seems at least vaguely useful... does this answer your question? If so, I'll write it up. But there are certainly caveats involved. – jcalz Aug 05 '21 at 14:29
  • Hey, genuinely thanks for going through this. I guess I was expecting result to just have the type of the final object you get as the result. So if the object is 2 levels deep you get `interface groupedRowType { [key: string]: { [key: string]: objectType[] }}` I am guessing that's not possible while also allowing other levels of obj depth depending on `[group, ...restGroups]`. The result from this is only used as arg in another function that's unique to each use case so I am just typing the arg there and keeping the result as any for now. – Newbie21 Aug 05 '21 at 15:15
  • [example](https://codesandbox.io/s/typescript-playground-export-forked-bn0yn?file=/index.ts:36-42) The only thing I would need typing in is figuring out if Object.entries in the `addNewRow()` is valid and that at the last depth you would have `objectType[]` as the value. If you have an better solution for this please let me know. – Newbie21 Aug 05 '21 at 15:17
  • So does [this](https://stackblitz.com/edit/typescript-fmhsbw?file=index.ts) work? It tracks the length of the `[group, ...restGroups]` array in the type system. If that's okay, I'll write up an answer for it. – jcalz Aug 05 '21 at 15:37
  • Yeah, this works great. So for the `doWeWantThis` part all the values for the keys in `[group, ...restGroups]` are going to be strings. Is that something we can enforce? – Newbie21 Aug 05 '21 at 16:00
  • Yes, like [this](https://stackblitz.com/edit/typescript-sswfth?file=index.ts). And we're getting more complicated. Is this the final version you want in the answer? – jcalz Aug 05 '21 at 16:05
  • I am a bit unsure if it's doing the right thing. The error for `doWeWantThis` [here](https://stackblitz.com/edit/typescript-mvja7b?file=index.ts) should be type of newArray['foo'] should be string and not {} – Newbie21 Aug 05 '21 at 17:23
  • Also seems like the solution isn't working on my local setup it's giving me the following error for `result`. `Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'O[] | { [k: string]: MultiGroupBy; }'. No index signature with a parameter of type 'string' was found on type 'O[] | { [k: string]: MultiGroupBy; }'` – Newbie21 Aug 05 '21 at 17:27
  • "The error for doWeWantThis here should be type of newArray['foo'] should be string and not {} " <-- what? I don't understand what you're talking about, sorry. The error is saying that `"foo"` is not a good grouping key and only `"bar"` is appropriate. If you are seeing something else I don't get it. Also I don't know what to say about your local setup; I need a [mcve] if I'm to help with an error. And please keep scope creep in mind; not every problem you run into will be appropriate for this question and you might want to open a new one. – jcalz Aug 05 '21 at 17:34
  • Sorry, you are right I misinterpreted the error. Yeah, the solution works. Unfortunately because of the other issue with the result, I haven't been able to use it. Seems like an issue with the tsconfig jsx option. You can check [here if you want](https://stackblitz.com/edit/typescript-mvja7b?file=multiGroupBy.ts) – Newbie21 Aug 05 '21 at 19:19
  • You can type in the answer it is correct even if I can't use it right now. – Newbie21 Aug 05 '21 at 19:25

1 Answers1

1

First it's important to determine what the input/output type relationship you want for multiGroupBy(). There is a spectrum of possible relationships; on one end of this spectrum you have simple typings that don't do you much good, like where no matter what you pass into multiGroupBy(), the type that comes out is object. On the other end you have incredibly complicated typings that can potentially represent the exact value that comes out, depending on what goes in... imagine a situation in which you call multiGroupBy([{x: "a", y: "b"}, {x: "a", y: "c"}], ["x", "y"]), and the return value has the type {a: {b: [{x: "a", y: "b"}], c: [{x: "a", y: "c"}]}}. This typing could potentially be easy to use, but at the cost of being quite difficult to implement, and possibly fragile.


So we need to strike a balance between those ends. The simplest typing that seems useful would look like this:

type MultiGroupBy<T> = (T[]) | { [k: string]: MultiGroupBy<T> };
declare const multiGroupBy: <O extends object>(
  array: O[],
  groups: (keyof O)[]
) => MultiGroupBy<O>;

If you input an array of type O[] and groups of type Array<keyof O>, then the output is of type MultiGroupBy<O>. Here we are representing the output as a union of either an array of the input types, or a dictionary-like object of arrays-or-dictionaries. The definition is infinitely recursive, and does not have a way of specifying how deep the object goes. In order to use this you'd have to test each level.

Also, when the output is a dictionary, the compiler has no idea what the keys of this dictionary will be. This is a limitation, and there are ways to address it, but it would make things quite complicated and since you've been fine with having unknown keys, I won't go into it.


So let's explore a typing that keeps track of how deep the output structure is:

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>;

Now multiGroupBy() takes an array of type O[], and a groups of type K, where K is constrained to be assignable to Array<keyof O>. And the output type, MultiGroupBy<T, K>, uses conditional types to determine what the output type will be. Briefly:

If K is an array of unknown length, then the compiler will output something very similar to the old definition of MultiGroupBy<T>, a union of arrays or nested dictionaries; that's really the best you can do if you don't know the array length at compile time. Otherwise, the compiler tries to see if the K is a tuple that can be split into its first element F and an tuple of the rest of the elements R. If it can, then the output type is a dictionary, whose values are of type MultiGroupBy<T, R>... this is a recursive step, and each time you go through the recursion, the tuple gets shorter by one element. If, on the other hand, the compiler cannot split K into a first-and-rest, then it's empty... and in that case, the output type is the T[] array.

So that typing looks pretty close to what we want.


We're not quite done, though. The above typing allows the keys in groups to be any key from the elements of array, including those where the property value is not a string:

const newArray = [{ foo: { a: 123 }, bar: 'hey' }];
const errors = multiGroupBy(newArray, ['foo']); // accepted?!

You don't want to allow that. So we have to make the typing of multiGroupBy() a bit more complicated:

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>;

The type KeysOfPropsWithStringValues<T> uses conditional types to find all the keys K of T where T[K] is assignable to string. It is a subtype of keyof T. You can write that other ways, such as in terms of KeysMatching from this answer, but it's the same.

And then we constrain K to Array<KeysOfPropsWithStringValues<O>> instead of Array<keyof O>. Which will work now:

const errors = multiGroupBy(newArray, ['foo']); // error!
// ----------------------------------> ~~~~~
// Type '"foo"' is not assignable to type '"bar"'.

Just to be sure we're happy with these typings, let's look at how the compiler sees an example usage:

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[];
    };
} */

Looks good! The compiler sees orderedGroupRows as a dictionary of dictionaries of arrays of ObjectType.


Finally, implementation. It turns out to be more or less impossible to implement a generic function returning a conditional type without using something like type assertions or any. See microsoft/TypeScript#33912 for more information. So here's about the best I can do without refactoring your code (and even if I did refactor it wouldn't get much better):

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
  );
};

If we had used the original union typing, the implementation would have been easier for the compiler to verify. But since we are using generic conditional types, we're not so lucky.

But on any case, I wouldn't worry too much about the implementation needing assertions. The point of these typings is so that callers of multiGroupBy() get strong type guarantees; it only has to be implemented once, and you can take care that you are doing it right since the compiler is not equipped to do so for you.


Stackblitz link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360