Typescript: Typeguard in conditional statement wrongly types the else case.

Created on 5 Feb 2019  路  6Comments  路  Source: microsoft/TypeScript

TypeScript Version: 3.4.0-dev.20190202

Search Terms: Type guard

Code

interface SampleStru<T> {
    decision: boolean; // Has decision about the data type.
}

class SampleClass<T, U>{

    public isTtype(input: SampleStru<T | U>): input is SampleStru<U> {
        return (input as SampleStru<U>).decision;
    }

    public sampleBug() {
        const data: SampleStru<T | U>;
        if (this.isTtype(data)) {
            // data is SampleStru<U>
        } else {
            // data is never. Expected behaviour SampleStru<T>
        }
    }
}

Expected behavior:
Since there are only two options (either data is SampleStru<T> or SampleStru<U>) the else case should correctly type data.
Actual behavior:
Data is typed as never.

Playground Link: link

Related Issues:

Working as Intended

Most helpful comment

This is the correct behaviour: all instantiations of SampleStru are structurally identical because the type parameter is never used in the body of the type. See here.

All 6 comments

This is the correct behaviour: all instantiations of SampleStru are structurally identical because the type parameter is never used in the body of the type. See here.

@jack-williams Fully agree with the structurally identical part. My guess is some structure got lost when creating the sample for the issue.

Even if they are not structurally identical, type-guards need a union in order to narrow the type on the else (essentially removing the member from the union). Samnple<T | U> is not a union. It is a generic type that happens to have a union as the type argument. Typescript will not attempt to do any narrowing in such cases:

interface SampleStru<T> {
    decision: boolean; // Has decision about the data type.
    value: T
}

class SampleClass {

    public isTtype(input: SampleStru<string | number>): input is SampleStru<number> {
        return typeof input.value === "number";
    }

    public sampleBug(data: SampleStru<string | number>) {

        if (this.isTtype(data)) {
            data.value // number
        } else {
            data.value //  string | number
        }
    }
}

You can think of data being typed on the este as Exclude<SampleStru<string | number>, SampleStru<number>> this will still SampleStru<string | number> .

So, am I correct in understanding that, in this case, SampleStru<T> | SampleStru<U> and SampleStru<T | U> are not the same?

I know that, for example, string[] | number[] is not equivalent to (string | number)[] since the later represents a mixed array.

@JAciv Exactly, they are not the same. (Assuming you actually use the type parameter, unlike your original code where you do not use it)

This narrows as expected:

interface SampleStru<T> {
    decision: boolean; // Has decision about the data type.
    value: T
}

class SampleClass<T, U>  {

    public isTtype(input: SampleStru<T> | SampleStru<U>): input is SampleStru<T> {
        return typeof input.value === "number";
    }

    public sampleBug(data: SampleStru<T> | SampleStru<U>) {

        if (this.isTtype(data)) {
            data.value // number
        } else {
            data.value //  string | number
        }
    }
}

Depending on what position a type parameter appears in, the different instantiations may relate co-variant or contra-variant way, and this has implications for assignability

interface SampleStru<T> {
    decision: boolean; // Has decision about the data type.
    value: T
}

// string extends string | number, T is in a covarinat position so co = Y
type co =  SampleStru<string> extends SampleStru<string | number> ? "Y": "N"
//but contra is "N"
type contra =  SampleStru2<string> extends SampleStru2<string | number> ? "Y": "N"
let a!:  SampleStru<string>;
let b!:  SampleStru<string | number>; 
a = b; /// not compatible
b = a; /// compatible


interface SampleStru2<T> {
    decision: boolean; // Has decision about the data type.
    value: (c:T) => void
}

// string extends string | number, T is in a contravariant position so co2 = N
type co2 =  SampleStru2<string> extends SampleStru2<string | number> ? "Y": "N"
// but o3 is "Y"
type contr2 =  SampleStru2<string | number> extends SampleStru2<string> ? "Y": "N"
let a2!:  SampleStru2<string>;
let b2!:  SampleStru2<string | number>;
a2 = b2; /// compatible
b2 = a2; /// not compatible

Side Note: An easy way to remember co vs contra variant is if the relationship is if the sub-type relationship is in the same direction for the type parameter and for the instantiation of the generic type. If Cat is a subclass of Animal then if G<Cat> is a subtype of G<Animal> the they are covariant. If G<Animal> is a subtype of G<Cat> the they are contravariant.

@dragomirtitian

It is a generic type that happens to have a union as the type argument. Typescript will not attempt to do any narrowing in such cases:

Strictly speaking this is mostly right, but there are exceptions. A type guard will also _narrow by assertion_. This basically amounts to replacing the source type by a direct subtype, or by adding an intersection of the predicate type.

In the case of a negated type guard, the source type will be filtered by all types assignable to the predicate type. In the OP, SampleStru<T | U> is assignable to SampleStru<U>, for reasons discussed, which means SampleStru<T | U> filters itself out and produces the never in the false branch.

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

dlaberge picture dlaberge  路  3Comments

Zlatkovsky picture Zlatkovsky  路  3Comments

DanielRosenwasser picture DanielRosenwasser  路  3Comments

MartynasZilinskas picture MartynasZilinskas  路  3Comments

wmaurer picture wmaurer  路  3Comments