typescriptobjectjavascript-objectstypescript-genericstyping

How to infer value type based on key in TypeScript object iteration?


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.


Solution

  • 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.

    Playground link to code