如何在打字稿中获取递归对象的类型作为函数参数?

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

我有一个有两个参数的函数 1:对象数组和 2:一个字符串数组,是对象之前的键

并根据它们创建分组。如何在以下递归函数中键入结果对象值 any

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'])

所以结果将是这样的递归结构,具体取决于字符串数组的长度

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

Here is a codesandbox为代码。

首先,重要的是确定 input/output 类型的 multiGroupBy() 关系。存在一系列可能的关系;在这个范围的一端,你有简单的类型,对你没有多大好处,比如无论你将什么传递到 multiGroupBy(),输出的类型都是 object。另一方面,你有非常复杂的类型,它可能代表输出的确切值,这取决于输入的内容......想象一下你调用 multiGroupBy([{x: "a", y: "b"}, {x: "a", y: "c"}], ["x", "y"]) 的情况,并且 return 值有类型 {a: {b: [{x: "a", y: "b"}], c: [{x: "a", y: "c"}]}}。这种类型可能很容易使用,但代价是很难实现,而且可能很脆弱。


所以我们需要在这些目的之间取得平衡。看起来有用的最简单的键入如下所示:

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

如果您输入 O[] 类型的 arrayArray<keyof O> 类型的 groups,则输出为 MultiGroupBy<O> 类型。在这里,我们将输出表示为输入类型数组的 union 或数组或字典的类似字典的对象。该定义是无限递归的,并且无法指定对象的深度。为了使用它,你必须测试每个级别。

此外,当输出是一个字典时,编译器不知道这个字典的键是什么。这是一个限制,并且有解决它的方法,但这会使事情变得非常复杂,并且由于您已经接受了未知密钥,所以我不会深入讨论它。


所以让我们探索一种跟踪输出结构有多深的类型:

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

现在 multiGroupBy() 接受类型 O[]array 和类型 Kgroups,其中 Kconstrained to be assignable to Array<keyof O>. And the output type, MultiGroupBy<T, K>, uses conditional types 来确定输出类型。简要地说:

如果K是一个未知长度的数组,那么编译器会输出一些与MultiGroupBy<T>的旧定义非常相似的东西,数组的并集或嵌套字典;如果您在编译时不知道数组长度,那确实是您能做的最好的事情。否则,编译器tries to see if the K is a tuple认为可以拆分成它的第一个元素F和其余元素R的一个元组。如果可以,那么输出类型是一个字典,它的值是 MultiGroupBy<T, R> 类型……这是一个递归步骤,每递归一次,元组就会短一个元素。另一方面,如果编译器无法将 K 拆分为先行后行,则它为空...在这种情况下,输出类型为 T[] 数组。

所以打字看起来非常接近我们想要的。


不过,我们还没有完全完成。上面的类型允许 groups 中的键是 array 元素中的 any 键,包括那些 属性 值不是字符串的键:

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

你不想允许。所以我们必须让 multiGroupBy() 的输入稍微复杂一点:

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

类型 KeysOfPropsWithStringValues<T> 使用条件类型查找 T 的所有键 K,其中 T[K] 可分配给 string。它是 keyof T 的子类型。你可以用其他方式来写,比如 中的 KeysMatching,但它是一样的。

然后我们将 K 约束为 Array<KeysOfPropsWithStringValues<O>> 而不是 Array<keyof O>。现在可以使用:

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

为了确保我们对这些类型感到满意,让我们看看编译器如何看待示例用法:

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

看起来不错!编译器将 orderedGroupRows 视为 ObjectType.

数组字典的字典

最后,实施。事实证明,在不使用 type assertions or any. See microsoft/TypeScript#33912 之类的东西以获取更多信息的情况下,实现通用函数 returning 条件类型几乎是不可能的。因此,这是我在不重构您的代码的情况下所能做的最好的事情(即使我重构了它也不会变得更好):

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

如果我们使用原始的联合类型,编译器将更容易验证实现。但是由于我们使用的是通用条件类型,所以我们就没那么幸运了。

但无论如何,我不会太担心需要断言的实现。这些类型的意义在于 multiGroupBy() 调用者 获得强类型保证;它只需执行一次,您可以确保自己的操作正确,因为编译器没有能力为您执行此操作。


Stackblitz link to code