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 中将对象键/值映射到具有相同键但不同值的对象的强类型的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持! 上岸,阿里云!
08-15 02:44