Given the following function that iterates over an object's properties:
function iterate<O extends Record<string, unknown>>(o: O, cb: (key: keyof O, value: O[keyof O], obj: O) => void) {
for (const key in o) {
if (o.hasOwnProperty(key)) {
cb(key, o[key], o);
}
}
}
In the following code, indexing into the object narrows its type, but checking the key name does not:
iterate({
name: "Sally",
age: 30,
}, (key, value, obj) => { // key: "name" | "age"; value: string | number
if (key === "name") {
const indexedValue = obj[key]; // indexedValue: string;
console.log(value.toUpperCase(), indexedValue.toUpperCase());
} else {
const indexedValue = obj[key]; // indexedValue: number;
console.log(value.toFixed(), indexedValue.toFixed());
}
});
As a result, value.toUpperCase()
is an error because value
might not be a string, but indexedValue.toUpperCase()
works because it's been narrowed. Similarly, value.toFixed()
is an error because value
might not be a number, but indexedValue.toFixed()
works because it's been narrowed.
Is there a way to specify the type of iterate
so that a conditional comparison of key
inside the callback can narrow value
? If not, why does indexing narrow the type while checking the key does not? They seem equivalent, but I wouldn't be surprised if there are cases I'm not thinking of where the two are not equivalent and hence the key comparison would not be safe as a way to narrow the value.
CodePudding user response:
You need to establish a relationship between key
and value
. As it currently stands, TS can't prove that "name"
will not be paired with a number
.
The way you can do this is by generating a discriminated union of tuples. For the given example this would be ["name", string] | ["age", number]
. Then use this as parameters for the callback. In recent version TypeScript (since 4.6 PR) can follow that variables destructured from a discriminated unions have a relationship between them and narrow value
when you narrow key
.
We ca do this using a mapped type to generate the tuple for each key in the type and then index into the mapped type to get the union of tuples.
type KeyValueEntries<T> = {
[P in keyof T]: [key: P, value: T[P], obj: T]
}[keyof T]
function iterate<O extends Record<string, unknown>>(o: O, cb: (...a:KeyValueEntries<O>) => void) {
for (const key in o) {
if (o.hasOwnProperty(key)) {
cb(key, o[key], o);
}
}
}
iterate({
name: "Sally",
age: 30,
}, (key, value, obj) => {
if (key === "name") {
const indexedValue = obj[key];
console.log(value.toUpperCase(), indexedValue.toUpperCase());
} else {
const indexedValue = obj[key];
console.log(value.toFixed(), indexedValue.toFixed());
}
});
CodePudding user response:
There is not enough information in the callback method to say that value is the type of obj[key].
Suppose that TypeScript picked up that obj[key]
and value
had the same type here. This casting allows us to imagine TypeScript was doing so:
if (key === "name") {
const indexedValue = obj[key];
console.log((value as string).toUpperCase(), indexedValue.toUpperCase());
} else {
const indexedValue = obj[key];
console.log((value as number).toFixed(), indexedValue.toFixed());
}
This is problematic - if you did this, and then swapped out iterate for this method, everything would still compile and crash at runtime:
function iterate<O extends Record<string, unknown>>(o: O, cb: (key: keyof O, value: O[keyof O], obj: O) => void) {
const keys: (keyof O)[] = Object.keys(o);
keys.forEach((key, i) => {
cb(key, o[keys[(i 1) % keys.length]], o);
})
}