在 TypeScript 中将对象键/值映射到具有相同键但不同值的对象的强类型 [英] Strongly type a map of object keys/values to an object with the same keys but different values in TypeScript

查看:26
本文介绍了在 TypeScript 中将对象键/值映射到具有相同键但不同值的对象的强类型的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

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

Object.map = (obj, fn) =>Object.fromEntries(Object.entries(obj).map(fn));//例子:const x = { foo: true, bar: false };const y = Object.map(x, ([key, value]) => [key, `${key} is ${value}.`]);console.log(y);

我试图在上面的示例中强输入 y.构建

令我困惑的是,即使我手动指定 Tstringy 仍然是 { foo: never;酒吧:从不;}.

const z =Object.map, idx: number) =>[keyof typeof x, string]>(x, entry => [entry[0], `${entry[0]} 是 ${entry[1]}.`]);Z 型 = z 型;/*** Z = { foo: 从不;酒吧:从不;}*/

有什么想法吗?

TS游乐场

解决方案

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

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

    type ExtractSupertype= T 扩展任何 ?[U] 扩展 [T] ?T:从不:从不;

    如果您使用它代替 Extract 并删除 readonly(因为 any[]readonly any[]),你就可以开始工作了.例如:

    ExtractSupertype[1]

  • 从一个只接受两个参数的函数推断四个类型参数不太可能顺利你真的不需要将 F 推断为 extends 它的约束.约束足够好:

    地图, fn: (entry: ObjectEntries, idx: number) => E): {[K in E[][number][0]]: ExtractSupertype[1] };

这些更改应该可以使您以前损坏的内容正常工作,但是我建议您更改其中的许多其他内容(或者至少希望听到一些解释):

  • 我不认为 T 是尝试推断的有用类型参数.

  • 我没有看到 E[][number]obj 被输入为 Readonly 的意义> 而不仅仅是 O.

  • 我不明白您为什么将 E[0] 限制为 keyof O.我认为要么允许更改密钥,在这种情况下只需将其限制为 PropertyKey,或者 不允许 允许更改密钥,在这种情况下不要t 接受一个完全返回键的回调.


我将为这个 map() 函数提供一些替代实现(我不会将它添加到 Object 构造函数中,因为猴子补丁构建-in 对象通常是不鼓励的),也许其中之一适合您.

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

type ObjectEntries= { [K in keyof T]: readonly [K, T[K]] }[keyof T];函数 mapValues(目标:T,f:(值:ObjectEntries<T>)=>伏): { [T 中的 K]: V } {返回 Object.fromEntries((Object.entries(obj) as [keyof T, any][]).map(e => [e[0], f(e)]))作为Record<keyofT,V>;}

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

const example = mapValues({ foo: true, bar: false }, entry => `${entry[0]} is ${entry[1]}.`);/* 常量示例:{富:字符串;酒吧:字符串;} */控制台日志(示例);/* {foo":foo 是真的.",bar":bar 是假的."} */

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

const 加宽 = mapValues({a: 1, b: "two", c: true}, e => e[1]);/* const 加宽:{a: 字符串 |数量 |布尔值;b: 字符串 |数量 |布尔值;c: 字符串 |数量 |布尔值;} */

我个人认为这不是问题;显然你不会将身份函数传递给 map()(有什么意义?)to string) 不会有编译器神奇地推断出的效果:

const nope = mapValues(obj, e => (typeof e[1] === "number" ? e[1] + "": e[1]))/* 常量不:{a: 字符串 |布尔值;b: 字符串 |布尔值;c: 字符串 |布尔值;} */

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

const notBetter = mapValues(obj, >(e: T) =>(typeof e[1] === "number" ? e[1] + "" : e[1]) asT[1] 扩展数 ?字符串 : 排除)/* const notBetter: {a: 字符串 |布尔值;b: 字符串 |布尔值;c: 字符串 |布尔值;} */

哦,好吧.


如果您确实想要支持转换键和值,我建议允许键成为任何 PropertyKey,并且您可能需要使输出 Partial 因为编译器不能保证每个可能的输出键实际上都是输出.这是我的尝试:

type GetValue=T 扩展任何 ?K 扩展 T[0] ?T[1]:从不:从不;function mapEntries(目标:T,f:(值:ObjectEntries,idx:数字,数组:ObjectEntries[]) =>电阻):{[R[0]中的K]?: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]}.`]);/* 常量示例 2: {foo?: 字符串 |不明确的;酒吧?:字符串|不明确的;} */控制台日志(示例2);/* {foo":foo 是真的.",bar":bar 是假的."} */

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

const requiresToBePartial = mapEntries(obj, e => e[0].charCodeAt(0) % 3 !== 0 ? e : [oops", 123] as const);/* const需要ToBePartial:{a?: 数字 |不明确的;b?: 字符串 |不明确的;c?: 布尔值 |不明确的;哎呀?:123 |不明确的;} */控制台日志(needsToBePartial);/* {一个":1,b":两个",哎呀":123} */

看看 needsToBePartial 在运行时如何缺少 c 属性.

现在有更多限制.一个身份函数做和以前一样的扩展,变成一个类似记录的类型:

const widened2 = mapEntries(obj, e => e);/* const 加宽:{a?: 字符串 |数量 |布尔值;b?: 字符串 |数量 |布尔值;c?: 字符串 |数量 |布尔值;} */

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

const widened3 = mapEntries(obj, (e: T) => e);/* 常量加宽3:{a?: 数字 |不明确的;b?: 字符串 |不明确的;c?: 布尔值 |不明确的;} */

你所做的任何比恒等函数更新颖的事情都会遇到和以前一样的问题:很难或不可能告诉编译器足够多的回调函数做了什么,让它推断出哪个可能的输出与哪个可能的键对应,然后你又得到了类似记录的东西:

const moreInterestingButNope = mapEntries(obj, (e) =>[({ a: "AYY", b: "BEE", c: "CEE" } as const)[e[0]], e[0]])/* const moreInterestingButNope: {AYY?:一个"|b"|c"|不明确的;蜜蜂?:一个"|b"|c"|不明确的;CEE?:一个"|b"|c"|不明确的;} */

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


游乐场链接到代码

I commonly need to take an object and produce a new object with the same keys but with values that are some mapping from KVP to some T. The JavaScript for this is straightforward:

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

I am trying to strongly type y in the example above. Building off Infer shape of result of Object.fromEntries() in TypeScript, I tried:

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

Mousing over .map shows that T isn't being inferred; it is of type unknown.

What's baffling to me is that even if I manually specify T is string, y is still { 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; }
 */

Any ideas?

TS Playground

解决方案

I'm just going to answer as fully as I can and hope that your use case is in there somewhere. First I'll try to assess the specific problems you were running into:

  • The thing that caused failure even when manually specifying your types was using Extract<T, U> to try to pull out the entry corresponding to a particular key. The Extract<T, U> splits up a union T into members, and checks that each one is assignable to U. If T is ["foo" | "bar", string], and if U is ["foo", any], then Extract<T, U> will be never because "foo" | "bar" is not assignable to "foo". It's the other way around. So maybe you want an ExtractSupertype<T, U> which keeps only those elements of the T union to which U is assignable. You can write that like this:

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

    If you use this in place of Extract and remove readonly (since any[] is a subtype of readonly any[]), you can get something to work. e.g.:

    ExtractSupertype<E[][number], [K, any]>[1]  
    

  • Inferring four type parameters from a function that takes only two arguments is not likely to go well You don't really need to infer F as anything that extends its constraint. The constraint is good enough:

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

Those changes should make your previously broken stuff work, but there's plenty of other stuff in there I'd recommend changing (or at least would want to hear some explanation for):

  • I don't see T as a useful type parameter to try to infer.

  • I don't see the point in E[][number] or that obj is typed as Readonly<O> instead of just O.

  • I don't understand why you are constraining E[0] to keyof O. I would think either allow the keys to be changed, in which case just constrain it to PropertyKey, or don't allow the keys to be changed, in which case don't accept a callback that returns keys at all.


I'm going to give some alternative implementations for this map() function (I'm not going to add it to the Object constructor since since monkey patching built-in objects is generally discouraged) and maybe one of them will work for you.

The simplest possible thing I can think of is to have the callback only return the property values and not the keys. We don't want to support changing the keys, right? So this will make changing the keys impossible:

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

You can see that this behaves as desired in your example case:

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."
} */

There are limitations here; the most notable one is that the output object will have all properties of the same type. The compiler won't and can't understand that a particular callback function produces different output types for different input properties. In the case of an identity-ish function callback, you can expect the implementation to do the right thing but the compiler to widen the returned object type to a record-like thing:

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

Personally I don't see this as much of a problem; obviously you're not going to be passing the identity function to map() (what's the point?) and any other function that does something novel (e.g., turns number values to string) is not going to have its effects magically inferred by the compiler:

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

Even if you go through the trouble of declaring the callback as a generic function that does the right thing, the compiler cannot perform the higher order reasoning in the type system to get the return type of f as something that changes depending on its argument type. There are some GitHub issues which have asked for that (I think microsoft/TypeScript#40179 is the most recent open issue about this) but nothing is there so far. So even with more effort you get widened types:

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 want to support transforming the keys as well as the values, I'd suggest allowing the keys to become any PropertyKey, and you will probably need to make the output Partial because the compiler cannot guarantee that every possible output key actually is output. Here's my attempt:

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

This is as close as I can get to your original code. It does about the same thing as yours to the original example, except for the optional keys:

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."
} */

You need those optional keys though; supporting keys being changed means you can't tell if every possible output key will actually be produced:

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 at runtime is missing the c property.

And now for more limitations. An identity function does the same widening thing as before, to a record-like type:

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

You can promote the callback to a generic and actually get a tighter type, but that's only because the union-of-entries input type becomes the same union-of-entries output type:

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

Anything you do which is more novel than an identity function suffers the same problem as before: it is hard or impossible to tell the compiler enough about what the callback function does to have it infer which possible output goes with which possible key, and you get a record-like thing again:

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

So even though this version uses an Extract-like return type to try to match each input entry with a corresponding output entry, it really isn't very useful in practice.


Playground link to code

这篇关于在 TypeScript 中将对象键/值映射到具有相同键但不同值的对象的强类型的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆