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

dx_*_*_dt 3 type-inference typescript

我通常需要获取一个对象并生成一个具有相同键的新对象,但其值是从 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);
Run Code Online (Sandbox Code Playgroud)

我正在尝试y在上面的示例中强输入。在 TypeScript 中构建Object.fromEntries() 结果的推断形状,我尝试了:

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; }
 */
Run Code Online (Sandbox Code Playgroud)

鼠标悬停.map显示T未推断;它属于 类型unknown

在此输入图像描述

让我困惑的是,即使我手动指定Tis stringy仍然是{ 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; }
 */
Run Code Online (Sandbox Code Playgroud)

有任何想法吗?

TS游乐场

jca*_*alz 5

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

  • 即使手动指定类型也会导致失败,原因Extract<T, U>是尝试提取与特定键对应的条目。将Extract<T, U>联合体拆分T为成员,并检查每个成员是否可分配 U。如果T["foo" | "bar", string],如果U["foo", any],则将Extract<T, U>是,never因为"foo" | "bar"不可分配给"foo"。恰恰相反。所以也许你想要一个只保留联合体可分配的ExtractSupertype<T, U>元素。你可以这样写:TU

    type ExtractSupertype<T, U> = T extends any ? [U] extends [T] ? T : never : never;
    
    Run Code Online (Sandbox Code Playgroud)

    如果您使用它来代替Extract并删除readonly(因为any[]是 的子类型readonly any[]),您可以得到一些东西。例如:

    ExtractSupertype<E[][number], [K, any]>[1]  
    
    Run Code Online (Sandbox Code Playgroud)
  • 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] };
    
    Run Code Online (Sandbox Code Playgroud)

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

  • 我不认为T这是一个有用的类型参数来尝试推断。

  • 我没有看到E[][number]obj键入为Readonly<O>而不是 的要点O

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


我将为此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>;
}
Run Code Online (Sandbox Code Playgroud)

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

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."
} */
Run Code Online (Sandbox Code Playgroud)

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

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;
} */
Run Code Online (Sandbox Code Playgroud)

就我个人而言,我不认为这是一个大问题;显然,您不会将 Identity 函数传递给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;
} */
Run Code Online (Sandbox Code Playgroud)

即使您经历了将回调声明为执行正确操作的泛型函数的麻烦,编译器也无法在类型系统中执行高阶推理来获取f根据其参数类型而变化的返回类型。有一些 GitHub 问题要求这样做(我认为microsoft/TypeScript#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;
} */
Run Code Online (Sandbox Code Playgroud)

那好吧。


如果您确实想支持转换键和值,我建议允许键变为 any 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;
}
Run Code Online (Sandbox Code Playgroud)

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

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."
} */
Run Code Online (Sandbox Code Playgroud)

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

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
} */
Run Code Online (Sandbox Code Playgroud)

查看needsToBePartial运行时如何缺少该c属性。

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

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

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

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

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

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;
} */
Run Code Online (Sandbox Code Playgroud)

因此,尽管此版本使用Extract类似返回类型来尝试将每个输入条目与相应的输出条目进行匹配,但它在实践中确实不是很有用。


Playground 代码链接