Search code examples
javascripttypescriptlodash

How to get the type of a recursive object as function argument in typescript?


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.


Solution

  • 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