类型安全的通用 reduce util 从数组创建对象

Type-safe generic reduce util to create object from array

我正在寻找一种通用且类型安全的方法来在 TypeScript 中对以下 JavaScript 进行建模:

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 中获得正确的类型。到目前为止,这是我想出的,但我需要创建两个函数才能使 return 类型正确,如果感觉有点老套的话。我无法获得条件 return 类型在这里工作,所以如果必须这样的话,我可以使用两个函数,但想知道这里是否有更好的方法(可能会导致 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]>);
}

P.S。我知道 lodash 有一个类似的 keyBy 功能,但我不认为它们有任何类似于上面显示的 keyByWith 的功能。

最大的问题是 records 被推断为类型:

{
    name: string;
    id: number;
    data: string[];
}[]

也就是说keyBy(records, 'name')只能还给你string。如果您将 as const 断言添加到 records,那么您可以获得一些文字字符串,并且您可以使用更强大的类型。

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"]

Playground

根据 Alex 的回答,实际上可以使用映射的对象类型完全推断出这种类型并正确区分它。但它肯定更冗长,需要一些按摩。

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

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

我继续使用其他答案中的一些工具来实现这个

//
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] 
} {...}

TS Playground

上查看此内容

这不完全是我问题的答案,因为它是一种完全不同的方法,但我一直在研究这个问题,认为值得分享使用回调的同一问题的替代解决方案,不是键,以提取结果对象的键和值。

这种方法与@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 Playground