I'm trying to iterate over an object in TypeScript where the type of the value depends on the key. I want TypeScript to narrow down the type of the value based on the key inside the loop. Here's my initial code:
interface MyInterface {
name: string;
age: number;
hobbies?: string[];
}
const obj: MyInterface = {
name: 'John',
age: 23,
};
for (const key in obj) {
const value = obj[key];
// TypeScript treats key as string, and value as any
// I want it to treat value as string when key is 'name', number when key is 'age', etc.
}
In the for...in
loop, TypeScript treats key
as string
and value
as any
. However, I want it to treat value
as string
when key
is 'name'
, number
when key
is 'age'
, and string[]
when key
is 'hobbies'
.
I tried to solve this by creating an awkwardly typed generator function that yields key-value pairs with their types:
function* iterateObjectTyped<T extends MyInterface, K extends keyof T>(
obj: T
): Generator<[K, T[K]]> {
for (const key in obj) {
const value = obj[key as unknown as K];
yield [key as unknown as K, value];
}
}
for (const [key, value] of iterateObjectTyped(obj)) {
// now TypeScript treats key as "name" | "age" | "hobbies"
// and value as string | number | string[]
// however:
if (key === 'age') {
value;
// it still treats value as just string | number | string[]
// even though I expected the above check to narrow down the predictions
}
}
But this doesn't work as expected. Even though TypeScript now treats key
as "name" | "age" | "hobbies"
and value
as string | number | string[]
inside the loop, it doesn't narrow down the type of value
based on the key
in the if
check.
Is there a way to achieve this in TypeScript? I'm using TypeScript 5.4.3. Any help would be appreciated.
Object types in TypeScript are open/extendible/"inexact" and not closed/sealed/"exact" (where "inexact"/"exact" are terminology from Flow). That means an object is allowed to contain more properties than TypeScript knows about:
const obj2 = { name: "John", age: 23, a: true, b: null };
const obj3: MyInterface = obj2; // okay
for (const key in obj3) {
const value = obj[key];
// ^? string
}
Here obj3
has properties a
and b
even though it is of type MyInterface
. So all TypeScript can truly say about key
is that it's of type string
, and therefore not a known key of MyInterface
, and therefore value
is of type any
.
See TypeScript: Object.keys return string[].
If you want, you can assert that an object only has the keys TypeScript knows about, that's fine. Just keep in mind that if you're wrong and something breaks, you're responsible for that.
Anyway, assuming you want to give your iterateObjectTyped
generator a call signature such that each element of the returned output is entry of [K, T[K]]
pairs for some K
in keyof T
, you can write it this way:
function* iterateObjectTyped<T extends object>(
obj: T
): Generator<{ [K in keyof T]-?: [K, T[K]] }[keyof T]> {
for (const key in obj) {
const value = obj[key];
yield [key, value];
}
}
The type { [K in keyof T]-?: [K, T[K]] }[keyof T]
is a so-called distributive object type as coined in microsoft/TypeScript#47109. It iterates over every key K
from T
, produces [K, T[K]]
, and then results in the union of them.
So now if we call iterateObjectTyped(obj)
you'll see:
// function iterateObjectTyped<MyInterface>(obj: MyInterface): Generator<
// ["name", string] | ["age", number] | ["hobbies", string[] | undefined]
// >
That's a union of pairs, and since the first element of each pair is a literal type, it acts as a discriminant property of a discriminated union:
for (const [key, value] of iterateObjectTyped(obj)) {
if (key === 'age') {
value; // const value: number
}
}
That works as expected because supports destructuring of discriminated unions into separate variables. Checking key === 'age'
will therefore narrow value
to number
.