Search code examples
typescripttype-narrowing

Type narrowing behavior not working depending on types involved


I have some type narrowing code that is causing a compiler error. When I make seemingly minor changes to the types involved in the narrowing, the compiler checks pass. The types are somewhat overlapping and involve optional attributes.

Are there any rules around type narrowing that might explain this behavior?

When the compile fails, the type that should have been narrowed does not appear to be narrowed -- in this case it is Pet. When you make a change to the types that compiles successfully, the type is Pet & Dog.

I hit this issue with production code, but have created a simple reproduction.

I checked previous TypeScript versions on the playground and the behavior seems to be the same, so I don't think this is new compiler behavior.

interface Pet {
  name: string; // remove this or make it nullable and it compiles.
  isFriendly: boolean; // remove this it compiles.
}

interface Dog {
  name: string; // remove this and it compiles
  ownerName?: string; // make this non-nullable and it compiles
  // isHappy: boolean; // Add any other non-nullable property and it compiles
}

function isDog(value: any): value is Dog {
  return true;
}

// change the type here to Pet | Dog, or Pet & Dog and it compiles
const testAnimal: Pet = {} as any;

if (isDog(testAnimal)) {
  // when the compiler error occurs, the type of `testAnimal` is `Pet` here, not `Dog` and not `Pet & Dog`
  console.log(testAnimal.ownerName); // <-- Compiler error here: Property 'ownerName' does not exist on type 'Pet'
}

Here is a link to the same code on the Typescript Playground


Solution

  • User-defined type guard functions can only have predicate return types that narrow — they can't widen or otherwise arbitrarily mutate the type of the operand parameter.

    Because of that, the predicate of your type guard needs to be an intersection of the input type and Dog, so:

    declare function isDog<T>(value: T): value is T & Dog;
    

    Using a generic type parameter for value is appropriate here in order to preserve that type information without needing to know it in advance (the compiler will infer it) — you could even constrain it by another more broad type (as appropriate to your use case — even something like object is better than nothing).

    Here's a complete example using the data you provided:

    TS Playground

    function hasOptionalProperty<
      O extends object,
      K extends PropertyKey,
      V,
    >(
      obj: O,
      prop: K,
      valueValidatorFn: (value: unknown) => value is V,
    ): obj is O & Partial<Record<K, V>> {
      return (
        !(prop in obj) ||
        valueValidatorFn((obj as Record<K, unknown>)[prop])
      );
    }
    
    interface Pet {
      name: string;
      isFriendly: boolean;
    }
    
    interface Dog {
      name: string;
      ownerName?: string;
    }
    
    function isDog<T>(value: T): value is T & Dog {
      return (
        typeof value === "object" && value !== null &&
        "name" in value && typeof value.name === "string" &&
        hasOptionalProperty(
          value,
          "ownerName",
          (value): value is string => typeof value === "string",
        )
      );
    }
    
    const testAnimal: Pet = { name: "Ani", isFriendly: true };
    
    if (isDog(testAnimal)) {
      console.log(testAnimal.ownerName); // Ok
                //^? const testAnimal: Pet & Dog
    }