Search code examples
typescriptdiscriminated-union

Typescript discriminated union narrowing not working


I have the following (simplified) code where I am trying to use discriminated union type. When I try to access data TS thinks it may be undefined and I am not sure why.

type Return =
  | {
    isReady: 'yes';
    data: { a: number };
  }
  | {
    isReady: 'no';
    data: false;
  };

const myFunction = (): Return => {
  return {
    isReady: 'yes',
    data: { a: 111 }
  };
}

const { isReady, data } = myFunction()

if (isReady === 'yes') {
  const { a } = data; // ERROR: Property 'a' does not exist on type '{ a: number; } | undefined'.
}

Solution

  • By destructuring the the discriminated union result of myFunction(), you have unfortunately destroyed any correlation the compiler could see between the isReady and the data properties:

    const { isReady, data } = myFunction()
    // const isReady: "yes" | "no"
    // const data: false | {  a: number; }
    

    Here, isReady and data are seen by the compiler to both be of union types, each with two members. That's not wrong, but the compiler now only sees the pair of isReady and data as having essentially four possible members:

    const oops: (
        | { isReady: "yes", data: false }
        | { isReady: "no", data: false }
        | { isReady: "yes", data: { a: number } }
        | { isReady: "no", data: { a: number } }
    ) = { isReady, data }
    

    And so checking isReady has no implication on data that the compiler can keep track of.


    This limitation in TypeScript's support for what I've been calling correlated union types is the subject of the feature request microsoft/TypeScript#30581. TypeScript 4.4 has introduced support for aliased conditions (as implemented in microsoft/TypeScript#44730) where you can store something like isReady into a variable and the compiler will use it later to discriminate your union:

    const result = myFunction();
    const isReady = result.isReady;
    
    if (isReady === 'yes') {
        const { a } = result.data; // okay in TypeScript 4.4
    }
    

    but as noted here,

    the pattern of destructuring a discriminant property and a payload property into two local variables and expecting a coupling between the two is not supported as the control flow analyzer doesn't "see" the connection. For example:

    type Data = { kind: 'str', payload: string } | { kind: 'num', payload: number };
    
    function foo({ kind, payload }: Data) {
        if (kind === 'str') {
            payload.length;  // Error, payload not narrowed to string
        }
    }
    

    We may be able to support that pattern later, but likely not in this PR.

    So for now there's no way to do this.


    The most plausible way forward for now is just not to destructure at all, and use discriminated unions the way they were meant to be used, as single objects with a discriminant property:

    const result = myFunction();
    if (result.isReady === 'yes') {
        const { a } = result.data; // okay
    }
    

    And I'll add this to the growing list of issues at microsoft/TypeScript#30581.

    Playground link to code