Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

type guards: typescript inferring never but it shouldn't

Tags:

typescript

The issue is pretty straightforward, related to type guards:

abstract class A {

    abstract isB(): this is B;
    abstract isC(): this is C;
    abstract get(): number;
}

class B extends A {

    isB(): this is B {
        return true;
    }

    isC(): this is C {
        return false;
    }

    get() {
        return 5;
    }
}

class C extends A {

    isB(): this is B {
        return false;
    }

    isC(): this is C {
        return true;
    }

    get() {
        return 6;
    }
}

const x = new C();
if (x.isB()) {
    console.log("B!")
} else {
    console.log(x.get()); <--- x is inferred to never
}

As you can see, at the next-to-last line, x is inferred to never while it's quite obviously C. I'm pretty sure it's this issue that I've hit, and I even I think understand what's the issue "Because Single is assignable to Empty, the code flow analysis is eliminating both of them from the union type".

However I don't understand how to take advantage of the workaround which is suggested.

I did check that the issue does not appear if I don't have A as a base class, but rather define B and C independently and then say type A = B | C. In that case typescript is even inferring the type to be C in the else block. I guess it's impossible to achieve such a nice type inference with inheritance, the way I have it set up currently?

like image 583
Emmanuel Touzery Avatar asked Jan 29 '23 00:01

Emmanuel Touzery


1 Answers

You're right about the issue; your B and C are structurally identical, and therefore TypeScript (up to and including v2.6) decides that if you have eliminated the possibility that x is B, then you have also eliminated the possibility that x is C. This is one of the features of a structural type system; if you want the compiler to distinguish two types, they should be distinguishable structurally (have some different members), not just nominally (have different names).

The easiest way to work around this is to add some distinguishing property to the definition of B or C or both. It doesn't even have to exist at runtime; it just has to convince the compiler that B and C are different. Here's one possibility:

class B extends A {

    readonly className: "B"  // add this line

    // ... no change

}

class C extends A {

    readonly className: "C"  // add this line

    // ... no change

}

Now B and C have a className property of different string literal types. (Only at compile time; nothing extra is emitted to JavaScript). Confirm that the problem goes away:

const x = new C();
if (x.isB()) {
    console.log("B!")
} else {
    console.log(x.get()); //<--- x is C
}

So, you can do that. Now, in TypeScript v2.7, which hasn't been released yet as of 2017-Dec-15, there will be a change that prevents the compiler from collapsing structually-identical-but-nominally-different types in certain circumstances. I'm not sure if your above code would suddenly start working without the extra properties, but it's a possibility. So check back soon, I guess.

Hope that helps. Good luck!

like image 104
jcalz Avatar answered Jan 31 '23 22:01

jcalz