Search code examples
typescripttypeguards

Why TypeScript type guard not narrowing down the union?


In the following TypeScript code, I expect contact to be narrowed down to GroupContact due to the type guard in place, but contact is still Contact | GroupContact. Why?

type Contact = {
    id: string;
    name: string;
    contactInfo: {
        isStarred: boolean;
    }
}

type GroupContact = Omit<Contact, "contactInfo">;

function isGroupContact(contact: Contact | GroupContact): contact is GroupContact {
    return !("contactInfo" in contact);
}

function foo(contact: Contact | GroupContact) {
    if (isGroupContact(contact)) {
        console.log("group", contact); // why contact is `Contact | GroupContact` here and not `GroupContact`?
    } else {
        console.log("not group", contact);
    }
}

Solution

  • Because TypeScript uses structural typing. Consider this example from the handbook:

    let o = { x: "hi", extra: 1 }; // ok
    let o2: { x: string } = o; // ok
    

    Here, the object literal { x: "hi", extra: 1 } has a matching literal type { x: string, extra: number }. That type is assignable to { x: string } since it has all the required properties and those properties have assignable types. The extra property doesn’t prevent assignment, it just makes it a subtype of { x: string }.

    The compiler is operating the same way on the code you showed as it does in the example: the derived type GroupContact looks like this when expanded:

    TS Playground

    type GroupContact = Omit<Contact, "contactInfo">;
    /*   ^?
    type GroupContact = {
      id: string;
      name: string;
    }
    */
    

    The GroupContact type doesn't restrict a property contactInfo from being present — it just means that the type must have the properties id and name of type string — which means that any value of type Contact is also assignable to GroupContact, so the type guard doesn't actually narrow.

    To indicate that the type "must not have a defined value at the property contactInfo", you can use an optional property of type never:

    type GroupContact = Omit<Contact, "contactInfo"> & { contactInfo?: never };
    

    …and narrowing will work as expected:

    function foo(contact: Contact | GroupContact) {
      if (isGroupContact(contact)) {
        console.log("group", contact);
        //                   ^? (parameter) contact: GroupContact
      } else {
        console.log("not group", contact);
        //                       ^? (parameter) contact: Contact
      }
    }
    

    Code in TS Playground


    See also: exactOptionalPropertyTypes