I'm trying to write a function in Typescript
that maps an object, while guaranteeing that it keeps the same keys. I've tried various approaches to this, but haven't found a way that works:
function mapObject1<K extends PropertyKey, A, B>(
object: { [P in K]: A },
mapper: (value: A) => B,
): { [P in K]: B } {
return Object.fromEntries(
Object.entries(object)
.map(([key, value]): [K, B] => [key, mapper(value)]),
); // error!
// Type '{ [k: string]: B; }' is not assignable to type '{ [P in K]: B; }'
}
export function mapObject2<K extends PropertyKey, A, B>(
object: { [P in K]: A },
mapper: (value: A) => B,
): { [P in K]: B } {
const result: { [P in K]?: B } = {};
(Object.keys(object) as K[]).forEach((key: K) => {
result[key] = mapper(object[key]);
});
return result; // error!
// Type '{ [P in K]?: B | undefined; }' is not assignable to type '{ [P in K]: B; }'
}
In mapObject1
, using Object.entries()
and Object.fromEntries()
causes the type of the keys to be converted to string
. In mapObject2
, result
's keys have to be optional since it starts out empty, causing Typescript
to not be able to recognize that all of the same keys as in object
are present. How should I approach solving this issue?
CodePudding user response:
Unfortunately the TypeScript compiler is unable to verify that the implementation is safe for a few reasons, and the most pragmatic way forward is to take special care that your implementation is written properly and then use type assertions to just tell the compiler that the values have the types you claim they have:
function mapObject1<K extends PropertyKey, A, B>(
object: { [P in K]: A },
mapper: (value: A) => B,
): { [P in K]: B } {
return Object.fromEntries(
Object.entries(object)
.map(([key, value]) => [key, mapper(value as A)]),
// assert --------------------------------> ^^^^^
) as { [P in K]: B }
//^^^^^^^^^^^^^^^^^^ <-- assert
}
function mapObject2<K extends PropertyKey, A, B>(
object: { [P in K]: A },
mapper: (value: A) => B,
): { [P in K]: B } {
const result: { [P in K]?: B } = {};
(Object.keys(object) as K[]).forEach((key: K) => {
// assert -------> ^^^^^^ (you already did this)
result[key] = mapper(object[key]);
});
return result as { [P in K]: B };
// assert --> ^^^^^^^^^^^^^^^^^^
}
That all compiles fine.
The reasons the compiler is unable to follow the logic:
The typings for the
Object.entries() method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/entries)
and (theObject.keys()
method do not restrict the keys ofobject
to just your generic typeK
. Object types in TypeScript are not "sealed", so objects may have more properties than the compiler knows about. So the compiler only returnsstring
for the key type andunknown
for the value type. See Why doesn't Object.keys return a keyof type in TypeScript? for more information.So some of the errors you're getting is the compiler trying to save you from problems like this:
interface Foo { x: number, y: number, z: number } const obj = { x: Math.LN2, y: Math.PI, z: Math.E, other: "abc" }; const foo: Foo = obj; // this assignment is okay const oops = mapObject1(foo, num => num.toFixed(2)); /* const oops: { x: string; y: string; z: string; } */ //