How to narrow the type of a discriminated union without explicitly enumerating the cases in Typescript?

70 Views Asked by At

So, I find myself to work with discriminated unions quite a bit, and I often encounter this issue:

I have a function with a parameter which type is a discriminated union, and I want to do something if said parameter belongs to a subset of the union

In order to do that, I can use the discriminant to narrow the type, and all is good.

The problem arises when the union grows in size, and I find myself with long chains of if (x.type === 'a' || x.type === 'b' || x.type === 'c' || ...)

In Javascript, in such cases I would just if (['a','b','c'].includes(x.type)), but this approach doesn't cut it for TS

Here is an example:

type Certificate = {
    type: 'NATIONAL_ID',
    nationality: string
} | {
    type: 'PASSPORT',
    passportNumber: string
} | {
    type : 'INSURANCE_CARD',
    provider: string
} | {
    type: 'BIRTH_CERTIFICATE',
    date: string
}| {
    type: 'DEATH_CERTIFICATE',
    date: string
}| {
    type: 'MARRIAGE_CERTIFICATE',
    date: string
}


// Works fine, but requires me to enumerate the discriminant cases one by one
const goodPrintDate = (certificate: Certificate) => {
    if (certificate.type === 'BIRTH_CERTIFICATE' || certificate.type === 'DEATH_CERTIFICATE' || certificate.type === 'MARRIAGE_CERTIFICATE') {
        console.log(certificate.date)
        return
    }

    console.log(`certificate of type ${certificate.type} has no date`)
}


// Doesn't work
const badPrintDate = (certificate: Certificate) => {
    const certificateTypesWithDate = ['BIRTH_CERTIFICATE', 'DEATH_CERTIFICATE', 'MARRIAGE_CERTIFICATE']

    if (certificateTypesWithDate.includes(certificate.type)) {
        // Only gets here if certificate type is 'BIRTH_CERTIFICATE', 'DEATH_CERTIFICATE', or 'MARRIAGE_CERTIFICATE', but TS does not know :( 
        console.log(certificate.date) //Property 'date' does not exist on type '{ type: "NATIONAL_ID"; nationality: string; }'
        return
    }
    
    console.log(`certificate of type ${certificate.type} has no date`)
}

So, is there a way for me to improve the syntax of goodPrintDate so that I don't need to explicitly enumerate every time? E.g. extracting that logic to a separate function (isCertificateWithDate / isCertificateTypeWithDate)

I've tried doing some type gymnastics, using sets instead of array, but nothing working

2

There are 2 best solutions below

0
Arno Hilke On

The closest solution I can think of for your problem is using switch with fall-through:

const printDate = (certificate: Certificate) => {
    switch (certificate.type) {
        case 'BIRTH_CERTIFICATE':
        case 'DEATH_CERTIFICATE':
        case 'MARRIAGE_CERTIFICATE':
            console.log(certificate.date) 
            return
        default:
            console.log(`certificate of type ${certificate.type} has no date`)
    }
}

It does list all relevant cases in a fairly readable way. If you want to explore other type-narrowing options, I recommend to read https://www.typescriptlang.org/docs/handbook/2/narrowing.html.

Whether includes should narrow down types was discussed before, e.g. in https://github.com/microsoft/TypeScript/issues/36275, but the TS team decided against it as the added complexity is not worth the added value. Feel free to check this out as well for a better understanding of the problem.

0
ghybs On

You can simply build a custom type predicate:

// Custom type predicate
function isCertificateWithDate(certificate: Certificate): certificate is BIRTH_CERTIFICATE | DEATH_CERTIFICATE | MARRIAGE_CERTIFICATE {
    return ['BIRTH_CERTIFICATE', 'DEATH_CERTIFICATE', 'MARRIAGE_CERTIFICATE'].includes(certificate.type)
}

const goodPrintDate = (certificate: Certificate) => {
    if (isCertificateWithDate(certificate)) {
        console.log(certificate.date) // Okay
        //          ^? (parameter) certificate: BIRTH_CERTIFICATE | DEATH_CERTIFICATE | MARRIAGE_CERTIFICATE
        return
    }

    console.log(`certificate of type ${certificate.type} has no date`)
    //                                 ^? (parameter) certificate: NATIONAL_ID | PASSPORT | INSURANCE_CARD
}

You could even build a more flexible version with a generic type:

// Custom flexible type predicate
function isCertificateType<T extends Certificate['type']>(certificate: Certificate, ...types: T[]): certificate is Certificate & { type: T } {
    return types.includes(certificate.type as any) // certificate.type may definitely not be in T
}

const goodPrintDate2 = (certificate: Certificate) => {
    if (isCertificateType(certificate, "BIRTH_CERTIFICATE", 'MARRIAGE_CERTIFICATE')) {
        console.log(certificate.date) // Okay
        //          ^? (parameter) certificate: BIRTH_CERTIFICATE | MARRIAGE_CERTIFICATE
        return
    }

    console.log(`certificate of type ${certificate.type} has no date`)
    //                                 ^? (parameter) certificate: NATIONAL_ID | PASSPORT | INSURANCE_CARD | DEATH_CERTIFICATE
}

Playground Link