typescript

Discriminated union type with discriminating property as union


I have a discriminated union (do I?) with discriminating property x of types number | undefined and number. I thought checking for x === undefined would suffice to narrow type down, but it doesn't work (typescript 5.7.3). Surprisingly it works if instead of number I use any literal type or even literal type union.

const assertIsOption1 = (option1: 'option1') => undefined;

type CreateUnionType<T> = (
    { x: T | undefined, y: 'option1'} |
    { x: T, y: 'option2' }
)

// Doesn't work with "broader types":

type UnionWithBroadTypeInDiscriminatoryKey = CreateUnionType<number>

const obj = { x: undefined, y: 'option1' } as UnionWithBroadTypeInDiscriminatoryKey;


if (obj.x === undefined) {
    assertIsOption1(obj.y)
}

// does work with literals:

type UnionWithLiteralTypeInDiscriminatoryKey = CreateUnionType<5>
const obj2 = { x: undefined, y: 'option1' } as UnionWithLiteralTypeInDiscriminatoryKey;

if (obj2.x === undefined) {
    assertIsOption1(obj2.y)
}

// and does work with union of literals:

type UnionWithLiteralTypeInDiscriminatoryKey2 = CreateUnionType<5 | 6 | 'some string literal'>
const obj3 = { x: undefined, y: 'option1' } as UnionWithLiteralTypeInDiscriminatoryKey2;

if (obj3.x === undefined) {
    assertIsOption1(obj3.y)
}

Click here to view and run this code on TypeScript Playground


Solution

  • Discriminated unions need to have a valid discriminant property that is a unit/literal type of a union of such types. The type undefined counts as a discriminant by itself, but neither the wide type number nor number | undefined counts, so you can't check your type's x property to discriminate the union. (Note that your type is a discriminated union with y being a valid discriminant, but for some reason you aren't trying to check that property.) This restriction could be considered a design limitation, as described at microsoft/TypeScript#30506; allowing wider discriminants is not part of the language, and judging from the issues linked within that issue, it doesn't look like it will become one soon (there is an implementation at microsoft/TypSeScript#60718 but apparently it breaks lots of real world code).

    There is some support for non-literal discriminants where some members of the union have wider type, but I believe at least one member of the union needs to be a "true" discriminant property. So if one union member has undefined as the discriminant, then other members can have wide types like number. This leads to the following refactoring of your types:

    type CreateUnionType<T> = (
        { x: undefined, y: 'option1' } |
        { x: T, y: 'option1' | 'option2' }
    )
    

    which works as intended:

    const obj = { x: undefined, y: 'option1' } as UnionWithBroadTypeInDiscriminatoryKey;
    if (obj.x === undefined) {
        assertIsOption1(obj.y); // okay
    }
    

    With the example code it does seem like you probably want to keep your original type and discriminate based on y to start with, but I guess that's out of scope for the question as asked.

    Playground link to code