类型安全的通用reduce工具,用于从数组创建对象

3
我希望你能提供一种通用且类型安全的方法,将以下JavaScript代码转换为TypeScript:
const records = [
  { name: "foo", id: 1, data: ["foo"] },
  { name: "bar", id: 2, data: ["bar"] },
  { name: "baz", id: 3, data: ["baz"] }
];

function keyBy(collection, k1, k2) {
  if (k2) {
    return collection.reduce((acc, curr) =>
      ({ ...acc, [curr[k1]]: curr[k2] }), {});
  } else {
    return collection.reduce((acc, curr) =>
      ({ ...acc, [curr[k1]]: curr }), {});
  }
}

console.log(keyBy(records, "name", "data"));
// { foo: [ 'foo' ], bar: [ 'bar' ], baz: [ 'baz' ] }

console.log(keyBy(records, "name"));
// {
//   foo: { name: 'foo', id: 1, data: [ 'foo' ] },
//   bar: { name: 'bar', id: 2, data: [ 'bar' ] },
//   baz: { name: 'baz', id: 3, data: [ 'baz' ] }
// }


这个工具的想法是将一个数组缩减为一个以给定键值为键的对象,并且其值可以是整个对象或者在给定第二个键处的特定数据点(这个解释可能有些欠佳,但希望示例可以说明问题)。
这是相当简单的JS代码,但在TS中似乎很难得到正确的类型。目前为止,我已经想出了下面的方法,但必须创建两个函数才能得到正确的返回类型,感觉有点笨拙。我无法让条件返回类型在这里起作用,所以如果必须要使用两个函数,那么就OK。但我想知道是否有更好的方法(也许可以将记录键入Record<T[K], T>Record<T[K], T[K2]>而不是记录键入ObjectKey)。谢谢。
type ObjectKey = string | number | symbol;

const isValidKey = (x: any): x is ObjectKey =>
  typeof x === "string" || typeof x === "number" || typeof x === "symbol";

function keyBy<T extends object, K extends keyof T>(collection: T[], key: K) {
  return collection.reduce((acc, curr) => {
    const valueAtKey = curr[key];

    if (isValidKey(valueAtKey)) {
      return { ...acc, [valueAtKey]: curr };
    }

    throw new Error("T[K] is not a valid object key type");
  }, {} as Record<KeyType, T>);
}

function keyByWith<T extends object, K extends keyof T, K2 extends keyof T>(
  collection: T[],
  k: K,
  k2: K2,
) {
  return collection.reduce((acc, curr) => {
    const valueAtKey = curr[k];

    if (isValidKey(valueAtKey)) {
      return { ...acc, [valueAtKey]: curr[k2] };
    }

    throw new Error("T[K] is not a valid object key type");
  }, {} as Record<ObjectKey, T[K2]>);
}

附言:我知道lodash有一个类似的keyBy函数,但我认为他们没有任何类似于上面展示的keyByWith

3个回答

2
最大的问题是records被推断为类型:
{
    name: string;
    id: number;
    data: string[];
}[]

这意味着keyBy(records,'name')只能返回string。如果在records中添加一个as const断言,则可以得到一些文字字面量,并且您可以使用更强的类型进行工作。
const records = [
  { name: "foo", id: 1, data: ["foo"] },
  { name: "bar", id: 2, data: ["bar"] },
  { name: "baz", id: 3, data: ["baz"] }
] as const;

接下来,您需要将 reduce 函数的结果对象输入:
Record<T[K] & ObjectKey, T>

或者

Record<T[K] & ObjectKey, T[K2]>

用于使用泛型 T 中的键。

带有无效键类型的 T[K] & ObjectKey 会解析为 never,但在此处还会抛出运行时异常,因此这并不重要。


最后,您可以使用重载来声明多个签名以创建此函数。这将有两个签名:

// One key
function keyBy<
  T extends object,
  K extends keyof T
>(
  collection: readonly T[],
  key: K
): Record<T[K] & ObjectKey, T>

// Two keys
function keyBy<
  T extends object,
  K extends keyof T,
  K2 extends keyof T
>(
  collection: readonly T[],
  k: K,
  k2: K2,
): Record<T[K] & ObjectKey, T[K2]>

可以使用以下类似的实现:

// Implementation
function keyBy<
  T extends object,
  K extends keyof T,
  K2 extends keyof T
>(
  collection: readonly T[],
  k: K,
  k2?: K2,
): Record<T[K] & ObjectKey, T[K2]> | Record<T[K] & ObjectKey, T> {
  return collection.reduce((acc, curr) => {
    const valueAtKey = curr[k];

    if (isValidKey(valueAtKey)) {
      if (k2) return { ...acc, [valueAtKey]: curr[k2] };
      return { ...acc, [valueAtKey]: curr };
    }

    throw new Error("T[K] is not a valid object key type");
  }, {} as Record<T[K] & ObjectKey, T[K2]> | Record<T[K] & ObjectKey, T>);
}

现在这个可以工作了:

const testA = keyBy(records, "name");
testA.foo.data // readonly ["foo"] | readonly ["bar"] | readonly ["baz"]

const testB = keyBy(records, "name", "data");
testB.foo // readonly ["foo"] | readonly ["bar"] | readonly ["baz"]

游乐场

太好了!这完美地解决了问题。我有一种感觉,过载可能会在这里帮助我。加上 T[K]&ObjectKey 以将记录的键缩小到 T[K],这些都是缺失的关键。在我的实际项目中,as const 不一定会有很大的区别,因为我通常会使用最初推断出记录数组的类型作为记录类型,例如 type User = { name: string; id: number: data: string[]; }。因此,能够将其作为 Record<string, User>Record<string, string[]> 得到是可以接受的,特别是如果我可以合并成一个单独的函数。 - no_stack_dub_sack
Alex,你是一个传奇,谢谢! - Moa

1

在Alex的回答基础上,使用映射对象类型实际上可以完全推断出该类型并正确区分它。但这确实更加冗长,并需要进行一些调整。

const testA = keyBy(records, "name");
testA.foo.data // readonly ["foo"]

const testB = keyBy(records, "name", "data");
testB.foo // readonly ["foo"]

我前往其他答案中获取了一些工具来实现这个

//https://dev59.com/A7roa4cB1Zd3GeqPm5I9
type AtLeastOne<T, U = {[K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];

type ExcludeEmpty<T> = T extends AtLeastOne<T> ? T : never; 

function keyBy<
  T extends Record<any, any>,
  K extends keyof T,
  K2 extends keyof T
>(
  collection: readonly T[],
  k: K,
): {
  [P in T[K]]: ExcludeEmpty<{
    [P2 in keyof T as T[K] extends P ? T[K] : never]: T
  }>[P]
}
function keyBy<
  T extends Record<any, any>,
  K extends keyof T,
  K2 extends keyof T
>(
  collection: readonly T[],
  k: K,
  k2: K2,
): {
  [P in T[K]]: ExcludeEmpty<{
    [P2 in keyof T as T[K] extends P ? T[K] : never]: T[K2]
  }>[P] 
}
// Implementation
function keyBy<T extends Record<any, any>, K extends keyof T, K2 extends keyof T>(
  collection: readonly T[],
  k: K,
  k2?: K2,
): {
  [P in T[K]]: ExcludeEmpty<{
    [P2 in keyof T as T[K] extends P ? T[K] : never]: T
  }>[P]
} | {
  [P in T[K]]: ExcludeEmpty<{
    [P2 in keyof T as T[K] extends P ? T[K] : never]: T[K2]
  }>[P] 
} {...}

View this on TS Playground


这很酷,确实以更严格的方式完成了工作,但在典型情况下,它可能不会改善太多。例如,如果我正在使用来自API的某些数据,而不是像此示例中应用as const的硬编码记录,则通常会有一个User[]Account[]等,这种情况下我认为结果不会有太大差异。如果我们从records数组中删除as const,并将其想象为User[],我认为结果将是相同的。尽管我确实从这个答案中学到了一些东西。谢谢! - no_stack_dub_sack
此外,有趣的是,即使使用 as const,当您拥有已知类型的记录数组时,它似乎并不像原始示例中处理方式一样:shorturl.at/nFIQ2。根据它处理原始示例的方式,我本来期望这里的 testA{ foo: Contact, bar: Account, baz: User; },但由于某种原因它只是 { [x: string]: Contact | Account | User }。为什么会有这种差异呢?两个示例都使用了 as const。我试图将其应用于更实际的用例以查看它如何处理。 - no_stack_dub_sack
1
@no_stack_dub_sack 这是因为在你的示例中,断言运算符 as 缩小了类型范围,例如将 name: 'foo' 缩小为 name: string。这基本上消除了我们用于区分值的方法,有许多针对此预期行为的“修复”方法,请参见此处:https://tsplay.dev/NBkeDm。基本上,这种方法严重依赖于元组的只读特性,并且使用 as 将其删除。这只涉及到 TS 相对于对象的可变性的预期行为以及它如何推断它。 - Cody Duong
啊!有趣的解决方法。感谢额外提供的信息! - no_stack_dub_sack
也许你的TS知识对我今天的另一个问题有用:https://dev59.com/CnIOtIcB2Jgan1znN9CW - no_stack_dub_sack

0

这并不完全回答我的问题,因为它是完全不同的方法,但我一直在尝试这个方法,并认为值得分享一个替代方案来解决相同的问题,它使用回调函数而不是键来提取结果对象的键和值。

这种方法具有与@AlexWayne的已接受答案相同的类型安全级别,但不如@CodyDuong的那么高。

然而,它支持更大的灵活性,以便对对象值进行数据转换(而不仅仅是限于TT[K]),并且不需要运行时检查以确保对象的键是有效的对象键(否则它将无法编译):

type User = {
  id: number;
  name: string;
  data: string[] | undefined;
};

const records: User[] = [
  { id: 1, name: "foo", data: ["fee"] },
  { id: 2, name: "baz", data: ["fi"] },
  { id: 3, name: "bar", data: undefined },
];

type ObjectKey = string | number | symbol;

type ToKey<T extends object, U extends ObjectKey> = (
  value: T,
  index: number
) => U;

type ToValue<T extends object, V> = (value: T, index: number, arr: T[]) => V;

function keyBy<T extends object, K extends ObjectKey>(
  collection: T[],
  keyExtractor: ToKey<T, K>
): Record<K, T>;

function keyBy<T extends object, K extends ObjectKey, V>(
  collection: T[],
  keyExtractor: ToKey<T, K>,
  valueExtractor?: ToValue<T, V>
): Record<K, V>;

function keyBy<T extends object, K extends ObjectKey, V>(
  collection: T[],
  keyExtractor: ToKey<T, K>,
  valueExtractor?: ToValue<T, V>
) {
  return collection.reduce<Record<K, T> | Record<K, V>>(
    (acc, curr, index, arr) => ({
      ...acc,
      [keyExtractor(curr, index)]: valueExtractor
        ? valueExtractor(curr, index, arr)
        : curr,
    }),
    {} as any
  );
}

const nameToData = keyBy(
  records,
  (x) => x.name,
  (x) => x.data
);

const nameToUser = keyBy(records, (x) => x.name);

const indexToUser = keyBy(records, (x, i) => i);

const indexToName = keyBy(
  records,
  (x, i) => i,
  (x) => x.name
);

const idToTransformedData = keyBy(
  records,
  (x, i) => i,
  (x) => x.data?.map((s) => [s.repeat(3)])
);

TS 游乐场


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接