Home > Software engineering >  Narrow type of object property based on key name
Narrow type of object property based on key name

Time:10-16

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

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

Playground Link

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