Skip to content

Type guard failures with expressions that can use for type guards #9862

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
falsandtru opened this issue Jul 21, 2016 · 13 comments
Closed

Type guard failures with expressions that can use for type guards #9862

falsandtru opened this issue Jul 21, 2016 · 13 comments
Assignees
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@falsandtru
Copy link
Contributor

TypeScript Version: 2.0.0

Code

class A<T> {
  private _: T;
}
class B<T> {
  private _: T;
}
function f() {
  var o: A<void> | B<void> = new A<void>();
  o instanceof A || o instanceof B;
  o; // A<any>, should be A<void> | B<void>
}

Expected behavior:

A type of o after expr is A<void> | B<void>.

Actual behavior:

A type of o after expr is A<any>.

@yortus
Copy link
Contributor

yortus commented Jul 21, 2016

Looks like same thing as #9861. It's due to the new A<void> assignment. The type of o from that point onward is narrowed to A<void>. Add an extra line before the expression to see this:

class A<T> {
  private _: T;
}
class B<T> {
  private _: T;
}
function f() {
  var o: A<void> | B<void> = new A<void>();
  o; // A<void>
  o instanceof A || o instanceof B;
  o; // A<any>, should be A<void> | B<void>
}

I'm not sure why it changes from A<void> to A<any> though....

@mhegazy mhegazy added the Duplicate An existing issue was already created label Jul 22, 2016
@mhegazy
Copy link
Contributor

mhegazy commented Jul 22, 2016

Same as #9861. please keep the discussion in #9859

@mhegazy mhegazy closed this as completed Jul 22, 2016
@yortus
Copy link
Contributor

yortus commented Jul 22, 2016

@mhegazy there is something odd about the inferred type of o changing from A<void> to A<any> after the expression, which is not captured in #9861. That part is a different issue - could it be a bug? Or otherwise is there an explanation for the change to the inferred type after the expression?

@mhegazy mhegazy reopened this Jul 22, 2016
@mhegazy
Copy link
Contributor

mhegazy commented Jul 22, 2016

you are right. it should be A<void>. this looks like a bug.

@mhegazy mhegazy added Bug A bug in TypeScript and removed Duplicate An existing issue was already created labels Jul 22, 2016
@yortus
Copy link
Contributor

yortus commented Jul 22, 2016

Another repro:

function f() {
    var s: Set<string> | Set<number>;

    s = new Set<number>();
    s; // Set<number>
    s instanceof Set;
    s; // Set<number>

    s = new Set<number>();
    s; // Set<number>
    s instanceof Set || s instanceof Set;
    s; // Set<any>                                <===== inferred type changed

    s = new Set<number>();
    s; // Set<number>
    s instanceof Set && s instanceof Set;
    s; // Set<any>                                <===== inferred type changed

    s = new Set<number>();
    s; // Set<number>
    s instanceof Set, s instanceof Set;
    s; // Set<number>

    s = new Set<number>();
    s; // Set<number>
    typeof s === 'object' || typeof s === 'object';
    s; // Set<number>
}

@RyanCavanaugh RyanCavanaugh added this to the TypeScript 2.0.1 milestone Aug 1, 2016
@sandersn
Copy link
Member

sandersn commented Aug 3, 2016

In master, the inferred type is now Set<number> | Set<any>. Which is still wrong.

@sandersn
Copy link
Member

sandersn commented Aug 3, 2016

Surprisingly, it is supposed to work this way! And after a lot of discussion with @ahejlsberg I think I can explain why.

You need to understand three facts:

  1. if affects control flow by splitting it in half and then unioning the result type of the two halves. See example.
  2. Conditional expressions are treated just like ifs, even if they're just by themselves in a statement, because side effects can happen in any expression.
  3. instanceof can only narrow the generic type A<T> to A<any> because, at runtime, there's no such thing as a type parameter and instanceof operates on the prototype, which will let any A through, regardless of the type of its members.

Example: If control flow

function f(x: string | number) {
    if (typeof x === 'string') {
        throw new Error("I don't handle strings after all!");
    }
    else {
        console.log('numbers are ok');
    }
    return x; // x: number
}

The then-branch of the if narrows x: string but then throws, so it results in x: never. The else-branch narrows x: number and then does nothing to alter the type. That means that the type of x after the if is x: number | never = number.

Complete example

Now let's look at a miniature version of the examples above:

function f(s: Set<string> | Set<number>) {
  s = new Set<number>();
  s instanceof Set || s instanceof Set;
  s; // Set<number> | Set<any>
}

The type of the lone s; gets Set<number> from the assignment s = new Set<number>();. The line s instanceof Set || s instanceof Set is treated like the then-branch of an if, and adds Set<any> to the type. Then else-branch is not present so it doesn't add any type to the union.

@sandersn sandersn added Working as Intended The behavior described is the intended behavior; this is not a bug and removed Bug A bug in TypeScript labels Aug 3, 2016
@sandersn sandersn closed this as completed Aug 3, 2016
@falsandtru
Copy link
Contributor Author

I understood, thanks.

@yortus
Copy link
Contributor

yortus commented Aug 4, 2016

Thanks @sandersn for the explanation, which makes it much clearer what's going on.

But isn't the compiler incorrectly performing narrowing in a clearly side-effect-free context? All of the following statements, on their own, cause s to be narrowed in subsequent statements, despite clearly having no effect on s:

  • s instanceof Set || 42
  • if (s instanceof Set) {/*empty*/}
  • if (s instanceof AnyRandomClass) {/*empty*/}

You've pointed out the reason for this in the current implementation, but perhaps there's scope for future improvement here? The core issue is the compiler's arguably strange 'narrowing' inference below:

let u = new Set<number>();
if (u instanceof Set) {
    u // u is Set<any>  !?
      // but we already knew u was a Set<number>,
      // so this type guard has widened it, not narrowed it!
}

That leads to the following version that I find hard to describe as 'working as intended':

function f(s: Set<string> | Set<number>) {

    // (1)
    s = new Set<number>(); // s is Set<number> after this assignment
    if (s instanceof Set) { } // Clearly no side-effects here. No possible changes to s.
    s.add(42); // ERROR: Cannot invoke an expression whose type lacks a call signature.
               // inferred type of x here is Set<number> | Set<any>

    // (2)
    s = new Set<number>(); // s is Set<number> after this assignment
    if (s instanceof Promise) { } // Clearly no side-effects here. No possible changes to s.
    s.add(42); // Works! No error this time!?
               // inferred type of x here is Set<number> | (Set<number> & Promise<any>)
}

@yortus
Copy link
Contributor

yortus commented Aug 4, 2016

Another example of instanceof narrowing strangeness:

// This part looks good:
let s: Set<string> | Set<number>;
if (s instanceof Set) {
    s // s is Set<string> | Set<number>, as expected
}
else {
    s.add(42); // ERROR 'add' does not exist on never, as expected
}

// This is not so good:
s = new Set<number>();
if (s instanceof Set) {
    s // s is Set<any>
}
else {
    s.add(42); // s is Set<number>
    // ^^^ no error this time, even though we couldn't possibly have a Set instance here
}

@sandersn
Copy link
Member

sandersn commented Aug 4, 2016

In your (2), @ahejlsberg has a PR (merged, I think?) that removes the weird behaviour when narrowing would produce never but instead unions on the guarded type plus the original type. It now produces never, which means that (2) would produce the correct type. I think it would fix the else-branch of your example (4) as well.

Maybe @ahejlsberg can comment on the efficiency (and design) concerns of analyzing blocks so that empty blocks would not have the bad behaviour in (1) and the others. I don't understand the overall design well enough to say.

@sandersn
Copy link
Member

sandersn commented Aug 4, 2016

I also also don't get why an un-initialized variable in (3) doesn't widen to Set but the assignment does in (4). I think it might be due to quirks in the handling of unions versus single (normal) types, but I'd have to read through the code to make sure.

@yortus
Copy link
Contributor

yortus commented Aug 5, 2016

@sandersn I've opened a new issue #10167 for discussion of the (1)-(4) cases above, since this is closed and the instanceof issues are not directly related to the OP problem here anyway.

@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

5 participants