Search code examples
typescript

Type narrowing at if statement seems not to work


I'm writing code for initializing some properties in custom class, but codes have some errors. Is there any other way to do this other than using "as"?

Here is the code that reproduces the problem:

class MyClass {
    a = "";
    b = true;
    c = new Date();
    d = new Point();
    //...other properties
}

//custom class
class Point {
    x = 0;
    y = 0;
}

const myc = new MyClass();

//properties that should be initialized
const array: (keyof MyClass)[] = ["c",];

array.forEach(prop => {
    //here myc[prop]'s type is string|boolean|Date|Point
    if (typeof myc[prop] === "object") {
        myc[prop] = Object.create(myc[prop]);//Error
    }
    else if (typeof myc[prop] == "string") {
        myc[prop] = "";//Error
    }
    //...

});

I want to narrow type by if(typeof ...), but it doesn't work.


Solution

  • TypeScript doesn't perform the sort of narrowing you are looking for here. Essentially you want to say that once you check obj[key] for reading you can assign the same type back to it for writing, even when key isn't known exactly... but that's not part of the language. There's a longstanding open feature request at microsoft/TypeScript#32693 to support this.


    Until and unless something like that is implemented, you'll need to work around it. One way that's a little more reusable than just writing type assertions is to make a custom type guard function that narrows key based on a narrowing of obj[key]. Possibly like this:

    type KeysMatching<T, V> = {
      [K in keyof T]-?: T[K] extends V ? K : never
    }[keyof T];
    
    function narrowKey<T extends object, V extends T[keyof T]>(
      obj: T, key: keyof T, guard: (x: T[keyof T]) => x is V
    ): key is KeysMatching<T, V> {
      return guard(obj[key]);
    }
    

    The utility type KeysMatching<T, V> is described at ttps://stackoverflow.com/q/54520676/2887218 and computes the set of keys of T whose values are assignable to V.

    The idea of narrowKey() is that you call narrowKey(obj, key, guard) where obj is of a generic object type T, and key is of type keyof T, and guard is a type guard function that narrows its input to type V. Then if guard(obj[key]) is true, key is narrowed to KeysMatching<T, V>.

    So given your definitions,

    class MyClass {
      a = "";
      b = true;
      c = new Date();
      d = new Point();
    }
    
    //custom class
    class Point {
      x = 0;
      y = 0;
    }
    
    const myc = new MyClass();    
    const array: (keyof MyClass)[] = ["c",];
    

    we can replace your direct typeof checks with narrowKey calls:

    array.forEach((prop) => {
      if (narrowKey(myc, prop, x => typeof x === "object")) {
        prop
        //^? (parameter) prop: "c" | "d"
        myc[prop] = Object.create(myc[prop]);
      } else if (narrowKey(myc, prop, x => typeof x === "string")) {
        prop
        //^? (parameter) prop: "a"
        myc[prop] = "";
      } else {
        prop
        //^? (parameter) prop: "b"
      }
    });
    

    Note that, as written, this requires TypeScript 5.5's inferred type predicates to infer that x => typeof x === "object" is of type (x: string | boolean | Date | Point) => x is Date | Point) and that x => typeof x === "string" is of type (x: string | boolean | Date | Point) => x is string). If you use an earlier version of TypeScript, you'd need to manually annotate the function return types to be x is Date | Point and x is string.

    Anyway, now there are no errors; if narrowKey(myc, prop, x => typeof x === "object") returns true, then prop is narrowed from keyof MyClass to KeysMatching<MyClass, Date | Point>, which is equivalent to "c" | "d". And then TypeScript is happy to allow you to assign Object.create(myc[prop]) to it (although this will just be of type any due to the typing of Object.create(). Then if narrowKey(myc, prop, x => typeof x === "string") returns true, prop is narrowed from "a" | "b" to "a", and then TypeScript knows that you're assigning "" to a property that accepts string.

    This sort of property key narrowing isn't perfect, but it is a little safer than just using type assertions when working around the lack of support for microsoft/TypeScript#32693.

    Playground link to code