在 TypeScript 中将对象键/值的映射强类型化为具有相同键但不同值的对象

2023-12-19

我通常需要获取一个对象并生成一个具有相同键但具有从 KVP 到某些映射的值的新对象T。 JavaScript 的实现很简单:

Object.map = (obj, fn) => Object.fromEntries(Object.entries(obj).map(fn));

// example:
const x = { foo: true, bar: false };
const y = Object.map(x, ([key, value]) => [key, `${key} is ${value}.`]);

console.log(y);

我正在尝试强类型y在上面的例子中。建设关闭推断 TypeScript 中 Object.fromEntries() 结果的形状 https://stackoverflow.com/questions/63061180/infer-shape-of-result-of-object-fromentries-in-typescript, 我试过:

type obj = Record<PropertyKey, any>;

type ObjectEntries<O extends obj> = {
  [key in keyof O]: [key, O[key]];
}[keyof O];

declare interface ObjectConstructor {
  /**
   * More intelligent declaration for `Object.fromEntries` that determines the shape of the result, if it can.
   * @param entries Array of entries.
   */
  fromEntries<
    P extends PropertyKey,
    A extends ReadonlyArray<readonly [P, any]>
  >(entries: A): { [K in A[number][0]]: Extract<A[number], readonly [K, any]>[1] };

  map<
    O extends obj,
    T,
    E extends readonly [keyof O, T],
    F extends (entry: ObjectEntries<O>, idx: number) => E
  >(obj: Readonly<O>, fn: F): { [K in E[][number][0]]: Extract<E[][number], readonly [K, any]>[1] };
}

Object.map = (obj, fn) => Object.fromEntries(Object.entries(obj).map(fn));

const x = { foo: true, bar: false } as const;
const y = Object.map(x, entry => [entry[0], `${entry[0]} is ${entry[1]}.`]);

type XEntries = ObjectEntries<typeof x>;
type Y = typeof y;
/**
 * XEntries = ['foo', true] | ['bar', false]
 * Y = { foo: never; bar: never; }
 */

鼠标悬停在.map表明T没有被推断;它是类型unknown.

让我感到困惑的是,即使我手动指定T is string, y还是{ foo: never; bar: never; }.

const z = 
  Object.map<
    typeof x, 
    string, 
    readonly [keyof typeof x, string], 
    (entry: ObjectEntries<typeof x>, idx: number) => [keyof typeof x, string]
  >(x, entry => [entry[0], `${entry[0]} is ${entry[1]}.`]);

type Z = typeof z;
/**
 * Z = { foo: never; bar: never; }
 */

有任何想法吗?

TS游乐场 https://www.typescriptlang.org/play?#code/C4TwDgpgBA9gRgKygXigJQgYxgJwCYA8ACjjJDqANIQgA0UAhgHYgB8A3ALABQPokUAPKIswAKJNgOAJYQAzgUFQIAD2AQmeObESsUUAN48oUANoBrGlGlMolkDABmQgLoAuM-fqCLNFy65uAF9fB2dBAJ4ePCwAGwYcaBt1HEcGTGhhBFEAYRgmOSkAV0xgXENjKAB6ACoayqgaqABZXCTJCFjY6QBzDWAoGMx4nAZgaXyoR3KAAyzRADpHUgBbCSlZORmoYAALMcGIFJWbeR3d6Dl9gSdz6ES5ItjgemlnaQHMZgWGpoABMAJBgrZSSGRnACCOFGIFgzn64LkP24JkaVUqyxgazBmwIDSgRGUag0WgJpHIVBotHxEKJ6k02gwDDw+ViIChMIIiWZrNhpiI9GYIBcrEqrAAFAjNh4IQBKDwGMyUay2CGmJhFFZwCA4FymAAM-g8YjUo1KBDVGq1Opc9G5LKYbKVgpYItMAEYXFAgoFKisGGA8SjUUpVPTSfAENTgyYACrR1FQMR0knae28zw0W6Ceix234gBiKYZUElYJAHnmpXWiMUrFeeBUHit2pwspQejEYvFkY8TIdbLr9EcTA8BflhiVKqTpj1LZtBqNSdN6WABDEs-Vmtbtqg6cdfMoLuFrA9Xp9PCCUW4VeAC39YH0PcQw6Y7eQelvS1WNc24q-UryM+CCyveAbiiOsqyr63DYAUAwqPoirTDAHjFBA9BwAkHhpLEcjQEEjDaHBhSBCRAywqgX4PuKKj0AilF6KYDGLvQMwACQGCxhqEdI2icSxnpBAsMwuNB178NAAAav5nFRIjVji8gEJJtwqBwfDgNAACa+iqc4ICBLU9Qok0MlKdoqCmAA5Ch1n0OhXoAD5mNZWE4PZUwMHhEAuMYTS6agyEwKhUBMBAABuOrsFA7nNpF0Xev56K8LB+SFFAABe+iVNRAZBom+lQHRUD4oUMhMD09D4vuTqhLcRUleVNg9Lu+JllIFZCAp4gWSpWlqfW1iNs2246u+TH2A1A3OE1GyVX5wYSiVDEdmY3G7hxXHlouvH8dtnVnsJonialklQAAWnpM1ZUZdT+ZdSFTCF8VRTgMVxWFCXvUlplVEAA


我将尽可能完整地回答,并希望您的用例在某处。首先,我将尝试评估您遇到的具体问题:

  • 即使手动指定类型也会导致失败的原因是使用Extract<T, U>尝试提取与特定键对应的条目。这Extract<T, U>分裂工会T到成员中,并检查每个成员是否可分配to U. If T is ["foo" | "bar", string], 而如果U is ["foo", any], then Extract<T, U>never因为"foo" | "bar"不可分配给"foo"。恰恰相反。所以也许你想要一个ExtractSupertype<T, U>只保留那些元素T union 到哪个U是可分配的。你可以这样写:

    type ExtractSupertype<T, U> = T extends any ? [U] extends [T] ? T : never : never;
    

    如果你用这个代替Extract并删除readonly (since any[]是一个子类型readonly any[]),你可以得到一些东西来工作。例如。:

    ExtractSupertype<E[][number], [K, any]>[1]  
    
  • 从仅采用两个参数的函数推断四个类型参数不太可能顺利您实际上不需要推断F作为任何事情extends它的约束。约束足够好:

    map<
      O extends obj,
      T,
      E extends readonly [keyof O, T],
    >(obj: Readonly<O>, fn: (entry: ObjectEntries<O>, idx: number) => E): {
      [K in E[][number][0]]: ExtractSupertype<E[][number], [K, any]>[1] };
    

这些更改应该可以使您之前损坏的东西正常工作,但是我建议更改其中还有很多其他东西(或者至少想听一些解释):

  • 我没看到T作为尝试推断的有用类型参数。

  • 我不明白其中的意义E[][number]或者那个obj键入为Readonly<O>而不是仅仅O.

  • 我不明白你为什么要约束E[0] to keyof O。我认为要么允许更改密钥,在这种情况下只需将其限制为PropertyKey, or don't允许更改键,在这种情况下根本不接受返回键的回调。


我将为此提供一些替代实现map()函数(我不会将其添加到Object构造函数,因为通常不鼓励猴子修补内置对象),也许其中之一适合您。

我能想到的最简单的事情是让回调仅返回属性值而不返回键。我们不想支持更改密钥,对吧?所以这将使更改密钥不可能的:

type ObjectEntries<T> = { [K in keyof T]: readonly [K, T[K]] }[keyof T];

function mapValues<T extends object, V>(
  obj: T,
  f: (value: ObjectEntries<T>) => V
): { [K in keyof T]: V } {
  return Object.fromEntries(
    (Object.entries(obj) as [keyof T, any][]).map(e => [e[0], f(e)])
  ) as Record<keyof T, V>;
}

您可以看到这在您的示例案例中表现得如预期:

const example = mapValues({ foo: true, bar: false }, entry => `${entry[0]} is ${entry[1]}.`);
/* const example: {
    foo: string;
    bar: string;
} */
console.log(example);
/* {
  "foo": "foo is true.",
  "bar": "bar is false."
} */

这里有一些限制;最值得注意的是输出对象将具有同一类型的所有属性。编译器不会也无法理解特定的回调函数为不同的输入属性生成不同的输出类型。在类似身份函数回调的情况下,您可以期望实现执行正确的操作,但编译器会将返回的对象类型扩展为类似记录的类型:

const widened = mapValues({a: 1, b: "two", c: true}, e => e[1]);
/* const widened: {
    a: string | number | boolean;
    b: string | number | boolean;
    c: string | number | boolean;
} */

就我个人而言,我不认为这是一个大问题;显然你不会将身份函数传递给map()(有什么意义?)以及任何其他执行新颖操作的函数(例如,转动number价值观string) 不会让编译器神奇地推断出其效果:

const nope = mapValues(obj, e => (typeof e[1] === "number" ? e[1] + "" : e[1]))
/* const nope: {
    a: string | boolean;
    b: string | boolean;
    c: string | boolean;
} */

即使您经历了将回调声明为执行正确操作的泛型函数的麻烦,编译器也无法在类型系统中执行高阶推理来获取返回类型f作为根据其参数类型而变化的东西。有一些 GitHub 问题要求这样做(我认为微软/TypeScript#40179 https://github.com/microsoft/TypeScript/issues/40179是关于此的最新开放问题)但到目前为止还没有任何进展。因此,即使付出更多努力,您也会得到更广泛的类型:

const notBetter = mapValues(obj, <T extends ObjectEntries<typeof obj>>(e: T) =>
  (typeof e[1] === "number" ? e[1] + "" : e[1]) as
  T[1] extends number ? string : Exclude<T[1], number>)
/* const notBetter: {
    a: string | boolean;
    b: string | boolean;
    c: string | boolean;
} */

Oh well.


If you do想要支持转换键和值,我建议允许键变成任何PropertyKey,你可能需要输出Partial因为编译器不能保证每个可能的输出键实际上都被输出。这是我的尝试:

type GetValue<T extends readonly [PropertyKey, any], K extends T[0]> =
  T extends any ? K extends T[0] ? T[1] : never : never;

function mapEntries<T extends object, R extends readonly [PropertyKey, any]>(
  obj: T,
  f: (value: ObjectEntries<T>, idx: number, array: ObjectEntries<T>[]) => R
): { [K in R[0]]?: GetValue<R, K> } {
  return Object.fromEntries(Object.entries(obj).map(f as any)) as any;
}

这是我能得到的最接近你的原始代码的结果。除了可选键之外,它的作用与原始示例的作用相同:

const example2 = mapEntries({ foo: true, bar: false }, entry => [entry[0], `${entry[0]} is ${entry[1]}.`]);
/* const example2: {
    foo?: string | undefined;
    bar?: string | undefined;
} */
console.log(example2);
/* {
  "foo": "foo is true.",
  "bar": "bar is false."
} */

不过你需要那些可选的键;支持更改密钥意味着您无法判断是否会实际生成每个可能的输出密钥:

const needsToBePartial = mapEntries(obj, e => e[0].charCodeAt(0) % 3 !== 0 ? e : ["oops", 123] as const);
/* const needsToBePartial: {
    a?: number | undefined;
    b?: string | undefined;
    c?: boolean | undefined;
    oops?: 123 | undefined;
} */
console.log(needsToBePartial);
/* {
  "a": 1,
  "b": "two",
  "oops": 123
} */

See how needsToBePartial在运行时缺少c财产。

现在还有更多限制。恒等函数执行与以前相同的扩展操作,扩展到类似记录的类型:

const widened2 = mapEntries(obj, e => e);
/* const widened: {
    a?: string | number | boolean;
    b?: string | number | boolean;
    c?: string | number | boolean;
} */

You can将回调提升为泛型并实际上获得更严格的类型,但这只是因为条目联合输入类型变成了相同的条目联合输出类型:

const widened3 = mapEntries(obj, <T,>(e: T) => e);
/* const widened3: {
    a?: number | undefined;
    b?: string | undefined;
    c?: boolean | undefined;
} */

你所做的任何比恒等函数更新颖的事情都会遇到与以前相同的问题:很难或不可能告诉编译器足够多的回调函数的作用,以使其推断哪个可能的输出与哪个可能的键对应,并且你得到又是一个类似记录的事情:

const moreInterestingButNope = mapEntries(
  obj, (e) => [({ a: "AYY", b: "BEE", c: "CEE" } as const)[e[0]], e[0]])
/* const moreInterestingButNope: {
    AYY?: "a" | "b" | "c" | undefined;
    BEE?: "a" | "b" | "c" | undefined;
    CEE?: "a" | "b" | "c" | undefined;
} */

所以即使这个版本使用了Extract- 就像返回类型尝试将每个输入条目与相应的输出条目相匹配,它在实践中确实不是很有用。


本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

在 TypeScript 中将对象键/值的映射强类型化为具有相同键但不同值的对象 的相关文章

随机推荐