Search code examples
typescriptdiscriminated-unionunion-types

Why is a discriminant needed for type guards of union types?


In the code below, why is typeof data.x === 'string' type guard insufficient to distinguish the union type?

TS playground

interface A { x: string, y: number }
interface B { x: number, y: string }

function handler(data: A | B) {
    if (typeof data.x === 'string') {
        data.y // string | number --- WHUT?
    }
}

In what case could handler be called with the (invalid) shape { x: string: y: string }?

Using a discriminant, it works (why?):

interface A { kind: 'A', x: string, y: number }
interface B { kind: 'B', x: number, y: string }

function handler(data: A | B) {
    if (data.kind === 'A') {
        data.y // number
    }
}

Reading offic docs on Discriminated Unions doesn't help.

They only state:

Some languages automatically discriminate unions for you; TypeScript instead builds on JavaScript patterns as they exist today.

That doesn't explain why TS can work with similar type guards in some cases, while not in the case of a union (I don't see any ambiguity in my first example).


Solution

  • Discriminated unions need to have a very particular structure. I once dug up the rules here:

    Narrowing the parent object is only done in specific scenarios, when the property is considered a discriminant for the union. A property is considered as a discriminant property if:

    • The property is a literal type as outlined here #9163
    • The a property of a union type to be a discriminant property if it has a union type containing at least one unit type and no instantiable types as outlined here #27695

    If those rules are not followed you just end up with a field discrimination not a parent object discrimination.