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?
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 (the Object.keys()
method do not restrict the keys of object
to just your generic type K
. Object types in TypeScript are not "sealed", so objects may have more properties than the compiler knows about. So the compiler only returns string
for the key type and unknown
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; } */
// 💥 num.toFixed is not a function !!!
The value foo
is of type Foo
because it's fine for there to be extra properties. But then you hit a runtime error inside your mapping function. In practice this is often a fairly unusual situation, so maybe you're convinced that this is an acceptable risk. If so, use the type assertion and move on. If not, then you will need to rewrite your function to accept an explicit list of keys to transform. But I consider that out of scope for the question as asked.
Even if the compiler knew/thought that Object.keys(object)
returned every single key, there's no way for it to understand that looping over these keys and setting a property in the return object will cause the return object to be promoted from a partial object to a full object. Doing so is safe (to the extent that Object.keys()
won't miss any required property keys), but verifying that is beyond the compiler's reasoning abilities. See How can i move away from a Partial<T> to T without casting in Typescript for more details. This is a great place to just use a type assertion and move on.