Search code examples
typescriptunion-types

Why does TypeScript narrow a type for some const declarations but not others?


UPDATED QUESTION (single primary question)

Given the code snippet below, why is the type narrowed when declaring a const to be Version but not for one which is declared as number?

type Version = 1 | 2 | 3;
const v: Version = 3;
if (v === 2) console.log('v equals 2'); // Fails compilation with "This comparison appears to be unintentional
                                        // because the types '3' and '2' have no overlap."
const n: number = 3;
if (n === 2) console.log('n equals 2'); // No compile error

ORIGINAL POST

Given the code snippet below:

  1. Why does the first comparison (v > 2) pass compilation but not the last (v === 2)?
  2. Why does the compile error for (v === 2) say ...types '3' and '2'... rather than ...types 'Version' and '2'?
type Version = 1 | 2 | 3;

const v: Version = 3;

if (v > 2) console.log('v is greater than 2');

if (v === 3) console.log('v equals 3');

if (v === 2) console.log('v equals 2'); // Fails compilation with "This comparison appears to be unintentional 
                                        // because the types '3' and '2' have no overlap."

Solution

  • See microsoft/TypeScript#16976 for an authoritative answer to this question.


    Generally speaking, narrowing tends only to operate by filtering union types. In

    type Version = 1 | 2 | 3;
    const v: Version = 3;
    ((v));
    //^? const v: 3
    

    TypeScript performs assignment narrowing on v from the union type 1 | 2 | 3 to just those members assignable to the initializer. That's 3, so v is seen as having the type 3 from then on.

    On the other hand,

    const n: number = 3;
    ((n));
    //^? const n: number
    

    no useful narrowing is performed. The type number is not a union. If you had something like

    const sn: string | number = 3;
    ((sn));
    //^? const sn: number
    

    then you'd see narrowing from string | number to number, because 3 is assignable to number but not string.


    So why doesn't narrowing happening for non-union types? Well, the obvious and unhelpful answer is that it hasn't been implemented. According to this comment in microsoft/TypeScript#16976,

    Currently there's no logic in effect for "narrowing" of non-unions.

    But the actual reason seems to be that it would be more complicated and difficult to do. For example in there I've linked to a relevant comment in microsoft/TypeScript#8513:

    The question of narrowing non-union types on assignment is one that we're still thinking about. It gets somewhat more complicated because [...] when optional properties are involved, the assigned type may actually have fewer members than the declared type. One possible mitigation is to use an intersection of the declared type and the assigned type:

    let a: { x?: number, y?: number };
    a = { x: 1 };
    a;  // Type { x: number, y: number | undefined };
    

    And I've also linked to a relevant issue, microsoft/TypeScript#10065 where it's said that

    Continuing to think about this but it'd be very expensive -- we'd be resynthesizing a type [...] after every assignment.

    It seems that filtering unions never introduces new types that TypeScript has to track (because the union already contains all the relevant types), but narrowing non-unions will often require TypeScript to synthesize and keep track of new types (like new intersections of the declared type and the assigned type), and this is expensive because it means extra work for every assignment.


    Playground link to code