Search code examples
typescriptnarrowing

Narrow type of object property based on key name


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.

TS Playground


Solution

  • 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());
      }
    });
    

    Playground Link