简体   繁体   English

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

[英]Type-safe generic reduce util to create object from array

I'm looking for a generic and type-safe way to model the following JavaScript in TypeScript:我正在寻找一种通用且类型安全的方式来 model 以下 JavaScript in 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' ] }
// }

The idea is to create a util that will reduce an array into an object keyed by the value at a given key, and with a value of either the entire object, or optionally a specific data point at a given second key (this explanation may be a bit poor, but hopefully the example speaks for itself).这个想法是创建一个 util,它将一个数组缩减为 object,该数组由给定键的值作为键控,并且值为整个 object,或者可选地为给定第二个键的特定数据点(此解释可能是有点差,但希望这个例子不言自明)。

This is pretty simple JS, but seems hard to get the types right in TS.这是非常简单的 JS,但似乎很难在 TS 中获得正确的类型。 Here's what I've come up with so far, but I've needed to create two functions in order to get the return types right and if all feels a bit hacky.到目前为止,这是我想出的,但我需要创建两个函数来获得正确的返回类型,如果一切都感觉有点老套的话。 I was unable to get a conditional return type to work here, so am OK with two functions if that's the way it has to be, but wondering if there's a better approach here (perhaps something that could result in Record<T[K], T> or Record<T[K], T[K2]> rather than the record being keyed by ObjectKey ).我无法在这里获得条件返回类型,所以如果必须这样的话,我可以使用两个函数,但想知道这里是否有更好的方法(可能会导致Record<T[K], T>Record<T[K], T[K2]>而不是由ObjectKey键控的记录)。 Thanks.谢谢。

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

PS I know lodash has a similar keyBy function, but I don't think they have anything similar to keyByWith shown above. PS 我知道 lodash 有一个类似的keyBy function,但我不认为他们有任何类似于上面显示的keyByWith的东西。

The biggest problem is that records is being inferred as type:最大的问题是records被推断为类型:

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

Which means keyBy(records, 'name') can only give you back string .这意味着keyBy(records, 'name')只能给你返回string If you add a as const assertion to records , then you can get some literal strings and you have stronger types to work with.如果您向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;

Then you need to type your reduce 'd result object as然后你需要输入你的reduce结果 object 作为

Record<T[K] & ObjectKey, T>

or或者

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

so that the keys from the generic T are used.以便使用通用T中的键。

The T[K] & ObjectKey with an invalid key type will resolve to never , but you will also throw a runtime exception there so that doesn't matter much.具有无效键类型的T[K] & ObjectKey将解析为never ,但您还会在那里抛出运行时异常,因此这无关紧要。


And lastly, you can can use overloading to declare multiple signatures to make this one function. This will have two signatures:最后,您可以使用重载来声明多个签名,使这个成为 function。这将有两个签名:

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

And an implementation with something like:以及类似的实现:

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

And now this works:现在这有效:

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 操场

Building off Alex's answer it is actually possible to infer this type fully and discriminate it properly, using mapped object types.根据亚历克斯的回答,实际上可以使用映射的 object 类型完全推断出这种类型并正确区分它。 But it definitely is more verbose and requires some massaging.但它肯定更冗长,需要一些按摩。

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

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

I went ahead and took some tools from other answers to achieve this我继续并从其他答案中获取了一些工具来实现这一目标

//https://stackoverflow.com/questions/61410242/is-it-possible-to-exclude-an-empty-object-from-a-union
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 PlaygroundTS 游乐场上查看

This isn't exactly an answer to my question since it's a completely different approach, but I've been playing around with this a bit more and thought it worth sharing an alternative solution to the same problem that uses callbacks, not keys, to extract both the key and value of the resulting object.这不完全是我问题的答案,因为它是一种完全不同的方法,但我一直在研究这个问题,并认为值得分享一个替代解决方案来解决使用回调而不是键来提取的同一问题结果 object 的键和值。

This approach has the same level of type safety as @AlexWayne's accepted answer, though not as much as @CodyDuong's.这种方法与@AlexWayne 接受的答案具有相同级别的类型安全性,尽管不如@CodyDuong 的那么多。

However, it supports greater flexibility in terms of data transformation for the object's values (rather than being limited to T or T[K] ), and does not require a runtime check to ensure the key of the object is a valid object key (rather it will just fail compilation if an invalid key key extractor is provided):但是,它在对象值的数据转换方面支持更大的灵活性(而不是限于TT[K] ),并且不需要运行时检查来确保 object 的密钥是有效的 object 密钥(而不是如果提供了无效的密钥密钥提取器,它只会编译失败):

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 TS游乐场

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM