Skip to content

Suggestion: successive narrowing with nested type guards #9016

Closed
@yortus

Description

@yortus
// Supported beast features
interface Beast     { wings?: boolean; legs?: number }
interface Legged    { legs: number; }
interface Winged    { wings: boolean; }

// Beast feature detection via user-defined type guards
function hasLegs(x: Beast): x is Legged { return x && typeof x.legs === 'number'; }
function hasWings(x: Beast): x is Winged { return x && !!x.wings; }

// Function to identify a given beast by detecting its features
function identifyBeast(beast: Beast) {

    // All beasts with legs
    if (hasLegs(beast)) {

        // All winged beasts with legs
        if (hasWings(beast)) {
            if (beast.legs === 4) { // ERROR TS2339: Property 'legs' does not exist on type 'Winged'.
                console.log(`pegasus - 4 legs, wings`);
            }
            else if (beast.legs === 2) { // ERROR TS2339...
                console.log(`bird - 2 legs, wings`);
            }
            else {
                console.log(`unknown - ${beast.legs} legs, wings`); // ERROR TS2339...
            }
        }

        // All non-winged beasts with legs
        else {
            console.log(`manbearpig - ${beast.legs} legs, no wings`);
        }
    }

    // All beasts without legs    
    else {
        if (hasWings(beast)) {
            console.log(`quetzalcoatl - no legs, wings`)
        }
        else {
            console.log(`snake - no legs, no wings`)
        }
    }
}

// Runtime results
identifyBeast({ wings: true });             // quetzalcoatl - no legs, wings
identifyBeast({ wings: false });            // snake - no legs, no wings
identifyBeast({ legs: 2 });                 // manbearpig - 2 legs, no wings
identifyBeast({ legs: 4 });                 // manbearpig - 4 legs, no wings
identifyBeast({ wings: true, legs: 2 });    // bird - 2 legs, wings
identifyBeast({ wings: true, legs: 4 });    // pegasus - 4 legs, wings
identifyBeast({ wings: true, legs: 6 });    // unknown - 6 legs, wings
identifyBeast({ wings: false, legs: 6 });   // manbearpig - 6 legs, no wings

The code above produces compiler errors with tsc@next, but it does represent valid JavaScript that runs correctly and outputs the expected results as shown at the bottom of the code.

I believe tsc is working as intended in this example. So this issue is a suggestion for improving type guards to support this coding pattern (sometimes called duck typing 🐥). The rationale is that it is a useful and common practice in JavaScript to identify and refine objects through successive feature detection.

The limitation with type guards in their current form is that if we have already narrowed to Legged, and then we further narrow to Winged using a nested type guard, then tsc 'forgets' that we still have a Legged. Imagine a blindfolded person feeling the legs of a beast. If further investigation reveals that the beast has wings, the person would probably assume the legs are still there. After all, legs and wings are not mutually exclusive features, and a beast can have both.

So the ideal behaviour in this case, would be that if we narrow to Legged and then further narrow to Winged, then its safe to say we must now have a Legged & Winged, ie both features simultaneously. (Strong duck typing? 💪 🐥)

A similar issue can be seen in the following snippet:

function beastFoo(beast: Beast) {
    if (hasWings(beast) && hasLegs(beast)) {
        beast // beast is Legged
        // ideally, beast would be Winged && Legged here...
    }

    if (hasLegs(beast) && hasWings(beast)) {
        beast // beast is Winged
        // ideally, beast would be Legged && Winged here...
    }
}

In summary, in a block of code that is only reachable by passing multiple type guards (eg isFoo(x) && isBar(x) && isBaz(x)), the narrowed type inside that block would be the intersection of the narrowed types from the type guards (eg x is Foo&Bar&Baz).

Metadata

Metadata

Assignees

No one assigned

    Labels

    DuplicateAn existing issue was already created

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions