如何将对象值提取为文字类型联合打字稿

How to extract object values as literal type union typescript

我正在实现一个 groupBy 函数,它基本上是这样的:

export const groupBy = <T extends Record<string, unknown>, U extends keyof T>(
  objArr: T[],
  property: U,
): { [key in T[U]]: T[] } => objArr
    .reduce((memo, x) => {
      const value = x[property];
      if (!memo[value]) {
        memo[value] = [];
      }
      memo[value].push(x);
      return memo;
    }, {} as { [key in T[U]]: Array<T> });

我知道打字不对,我把它搞得一团糟:

const data = [
   { name: 'corn cob', value: 17, group: 'item' },
   { name: 'Dirty toilet', value: 6, group: 'item' },
   { name: 'snake', value: 2, group: 'animal' },
   { name: 'tesla', value: 17, group: 'car' },
   { name: 'gurgel', value: 23, group: 'car' },
  ];

const result = groupBy(data, 'group')

{
  item: [
    { name: 'corn cob', value: 17, group: 'item' },
    { name: 'Dirty toilet', value: 6, group: 'item' }
  ],
  animal: [ { name: 'snake', value: 2, group: 'animal' } ],
  car: [
    { name: 'tesla', value: 17, group: 'car' },
    { name: 'gurgel', value: 23, group: 'car' }
  ]
}

这是我能找到的最大类型安全,但我仍然在 T[U].

上遇到一些错误

Type 'T[U]' is not assignable to type 'string | number | symbol'.

而return类型如下:

const result: {
    [x: string]: {
        name: string;
        value: number;
        group: string;
    }[];
}

有什么方法可以实现这种 return 类型:

const result: {
    item: {
        name: string;
        value: number;
        group: string;
    }[];
    animal: {
        name: string;
        value: number;
        group: string;
    }[];
    car: {
        name: string;
        value: number;
        group: string;
    }[];
}

如果我可以将给定键的所有输入对象值提取为如下文字:

const test = [
  { name: 'corn cob', value: 17, group: 'item' },
  { name: 'Dirty toilet', value: 6, group: 'item' },
  { name: 'snake', value: 2, group: 'animal' },
  { name: 'tesla', value: 17, group: 'car' },
  { name: 'gurgel', value: 23, group: 'car' },
] as const ;


type ValueAtKey = (typeof test)[number]['group']; // "item" | "animal" | "car"

但是我如何在通用函数中执行此操作 const asertion

做这样的事情:

type SomeMagicType<T extends Record<string, unknown>[], U extends keyof T[number]> = T[number][U];

export const groupBy = <T extends Record<string, unknown>, U extends keyof T>(
  objArr: T[],
  property: U,
): { [key in SomeMagicType<T, U>]: T[] } => objArr
    .reduce((memo, x) => {
      const value = x[property];
      if (!memo[value]) {
        memo[value] = [];
      }
      memo[value].push(x);
      return memo;
    }, {} as { [key in SomeMagicType<T, U>]: Array<T> });

如果不可能,那么我该如何消除上述错误。我不明白为什么 Type 'T[U]' is not assignable to type 'string | number | symbol' if T extends Record<string, unknown>.

TSPlayground

首先,如果您希望 groupBy 的 return 类型具有特定的键,例如 itemanimalcar,您将非常需要做类似 const assertion when you initialize data. Generally speaking, the compiler infers the type of a string-valued property to be string and not the specific string literal type 的事情。因此 const x = {a: "b"}x 的类型将被推断为 {a: string} 而不是 {a: "b"}。由于 "item""animal""car"data 的字符串值属性,您需要做一些工作来保留它们的值。最简单的做法是:

const data = [
  { name: 'corn cob', value: 17, group: 'item' },
  { name: 'Dirty toilet', value: 6, group: 'item' },
  { name: 'snake', value: 2, group: 'animal' },
  { name: 'tesla', value: 17, group: 'car' },
  { name: 'gurgel', value: 23, group: 'car' },
] as const;

尽管现在编译器会记住很多关于 data:

的结构
/* const data: readonly [{
    readonly name: "corn cob";
    readonly value: 17;
    readonly group: "item";
}, {
    readonly name: "Dirty toilet";
    readonly value: 6;
    readonly group: "item";
}, {
    readonly name: "snake";
    readonly value: 2;
    readonly group: "animal";
}, {
    readonly name: "tesla";
    readonly value: 17;
    readonly group: "car";
}, {
    readonly name: "gurgel";
    readonly value: 23;
    readonly group: "car";
}] */

而你只真正关心 group 的文字类型。你可以做一些更复杂的事情,比如

const data = [
  { name: 'corn cob', value: 17, group: 'item' as const },
  { name: 'Dirty toilet', value: 6, group: 'item' as const },
  { name: 'snake', value: 2, group: 'animal' as const },
  { name: 'tesla', value: 17, group: 'car' as const },
  { name: 'gurgel', value: 23, group: 'car' as const },
];

这导致

/* const data: ({
    name: string;
    value: number;
    group: "item";
} | {
    name: string;
    value: number;
    group: "animal";
} | {
    name: string;
    value: number;
    group: "car";
})[] */

我猜哪个没那么难看。由你决定。


无论如何,一旦你有足够的强类型 data,我们可以给 groupBy 一个正确使用它的调用签名。让我们开始吧:

declare const groupBy:
  <T extends Record<string, PropertyKey>, K extends keyof T>(
    objArr: readonly T[],
    property: K,
  ) => Record<T[K], T[]>

这与您的版本相似(除了 readonly T[]T[] 更宽松,并且从 U 重命名为更传统的 K 以表示“钥匙”)。这里重要的区别是我已经将 T 的值类型从 unknown 更改为 PropertyKey 一个 standard library defined type 等同于 string | number | symbol,可用作键的类型.所以现在编译器知道 T[K] 本身是类似键的,并且可以在 Record<T[K], T[]>.

中使用

当然我们并不是真的想说每个 属性 in T 都需要是keylike;我们只想将键 K 处的属性限制为该类型。所以我们可以重写调用签名:

declare const groupBy:
  <T extends Record<K, PropertyKey>, K extends keyof T>(
    objArr: readonly T[],
    property: K,
  ) => Record<T[K], T[]>

现在 T 取决于 K。我们可以将 K extends keyof T 的约束放宽到 K extends PropertyKey,因为 T 的约束现在保证 K 是一个键。但这取决于你。


无论如何,现在这会起作用,但是当我们查看结果的类型时,那个巨大的 const-asserted 类型真的会回到我们身边:

const resultOrig = groupBy(data, 'group');
/* const resultOrig: Record<"item" | "animal" | "car", ({
    readonly name: "corn cob";
    readonly value: 17;
    readonly group: "item";
} | {
    readonly name: "Dirty toilet";
    readonly value: 6;
    readonly group: "item";
} | {
    readonly name: "snake";
    readonly value: 2;
    readonly group: "animal";
} | {
    readonly name: "tesla";
    readonly value: 17;
    readonly group: "car";
} | {
    readonly name: "gurgel";
    readonly value: 23;
    readonly group: "car";
})[]> */

那个类型是正确的,但你提到你想要一些不太具体的 属性 值。你可以让编译器计算出这样的类型,但这并不简单:

type WidenLiteral<T> = 
  T extends string ? string : 
  T extends number ? number : 
  T extends boolean ? boolean : 
  T;

declare const groupBy: <T extends Record<K, PropertyKey>, K extends PropertyKey>(
   objArr: readonly T[], property: K) => Record<T[K], { 
     [P in PropertyKey & keyof T]: WidenLiteral<T[P]>; 
 }[]> */

我在那里所做的是编写一个类型 WidenLiteral<T>,它将一个类型转换为扩展版本,其中任何 stringnumberboolean 文字类型分别扩展为 stringnumberboolean。所以WidenLiteral<"a" | Date>会变成string | DateWidenLiteral<1 | 2 | 3 | 4>会变成number.

然后,我 return 不是 return 数组 T[],而是数组 {[P in PropertyKey & keyof T]: WidenLiteral<T[P]>}。它所做的是采用 T 类型,如果它是一个联合,它会将它折叠成一个单一类型(并忘记联合的任何一个成员不存在的任何属性),然后它扩大文字WidenLiteral 的属性。所以如果输入是{a: 0, b: "a", c: true} | {a: 1, b: "b", d: false},输出就是{a: number, b: string}.

请注意,当 mapped type over T is not 时会自动发生这种情况,因此 {[P in keyof T]: 0} 是同态的,并且会将 {a: 1, c: 1} | {a: 2, d: 2} 变成 {a: 0, c: 0} | {a: 0, d: 0}{[P in (PropertyKey & keyof T)]: 0} 不是同态的并且会将 {a: 1, c: 1} | {a: 2, d: 2} 变成 {a: 0}.

无论如何,这个调用签名会把 data 的乱七八糟的类型变成更令人愉快的东西:

const result = groupBy(data, 'group');

/* const result: Record<"item" | "animal" | "car", {
    group: string;
    name: string;
    value: number;
}[]> */

太棒了!


哦,当然 groupBy 的实现至少需要一个 type assertion:

const groupBy = <T extends Record<K, PropertyKey>, K extends PropertyKey>(
  objArr: readonly T[],
  property: K,
) => objArr
  .reduce((memo, x) => {
    const value = x[property];
    if (!memo[value]) {
      memo[value] = [];
    }
    memo[value].push(x as any); // <-- here
    return memo;
  }, {} as Record<T[K], { [P in (PropertyKey & keyof T)]: WidenLiteral<T[P]> }[]>);

这里需要类型断言,因为虽然 x 显然是 T 类型的值,但它不是 {[P in ...]: WidenLiteral<...>} 类型的值。好吧,反正不是编译器。编译器不擅长对尚未指定的泛型类型进行高阶推理,例如 groupBy.

主体中的 T

所以从实现者的角度来看,这有点不安全,你需要确保你写得正确。编译器无法真正捕捉到类型断言的所有错误。所以 push(x as any)push(123 as any) 在编译器看来是一样的。

但是从调用者的角度来看,这应该能很好地工作(只要调用者使用特定类型而不是未指定的泛型)。

Playground link to code