Skip to content
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

Strange flow analysis for guards applied to a variable having an initial type of 'nothing' #8374

Closed
malibuzios opened this issue Apr 29, 2016 · 4 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@malibuzios
Copy link

malibuzios commented Apr 29, 2016

TypeScript Version:

1.9.0-dev.20160429

Summary:
For the purpose of captured variable assignment analysis, I'm trying to simulate a variation of the current analysis where a captured variable would start from a 'blank' state (i.e. the nothing type). This is in order to isolate the possible side-effects that may be caused to it in the body of the function (or through calls to other side-effect causing functions). It turns out this exposed some very strange behavior of the current analysis for this particular scenario:

Code:

declare let x: number | string;

function func() {
    x; // (#1) 'x' has type 'number | string' here

    if (typeof x !== "number" && typeof x !== "string") {

        x; // (#2) 'x' has type 'nothing' here

        if (typeof x === "number") {
            x; // (#3) 'x' has type 'number' here?
        }

        x; // (#4) 'x' has type 'number' here?

        if (typeof x === "string") {
            x; // (#5) 'x' has type 'nothing' here (?)
        }

        x; // (#6) 'x' has type 'number' here??

        return;
    }
}

Remarks:
I'll try to look at each position individually:

  1. Expected.
  2. Expected.
  3. Since Javascript is single threaded, then one can positively assert that the if (typeof x === "number") branch would never be executed unless x has been reassigned somewhere after the initial 'negative' guard, so the type could also be nothing, just like in #5.
  4. It can also be nothing here.
  5. Strangely it is already nothing here?
  6. It can also be nothing here.

Another scenario:
Here's another strange scenario:

declare let x: number | string;

function func() {
    x; // (#1) 'x' has type 'number | string' here

    if (typeof x !== "number" && typeof x !== "string") {

        x; // (#2) 'x' has type 'nothing' here

        if (typeof x === "number") {
            x; // (#3) 'x' has type 'number' here?

            x = "abcd";
            x; // (#4) 'x' has type 'string' here
        }

        x; // (#5) 'x' has type 'string' here? 

        return;
    }
}

(#4) is OK, I believe.
In (#5) the compiler may assume the conditional branch above was never executed and fall back to nothing. However, for the purpose of side-effect analysis this behavior may actually turn out to be beneficial, so I'm not sure at this point.

Suggestion:
I believe there could be a simple rule to resolve this, something like:

"If a variable having a current type of nothing is passed through a guard, then the guard is always expected to fail and the type would resolve to nothing within the guard's conditional body. Reassignments in the guarded branch would impact the type locally but not affect statements following it"

(although reasonable, I'm not sure if the reassignment rule is really helpful for the sort of analysis that would be needed for captured variables, though).

It may not turn out to be that simple in practice, though, I'm not sure.

@malibuzios
Copy link
Author

malibuzios commented Apr 29, 2016

I noticed a much simpler scenario that has somewhat unexpected behavior, that may or may not be related.

Here, a type is ruled out based on a negative guard:

declare let x: number | string;

if (typeof x !== "number") {
    x; // 'x' has type 'string', as expected
}

And here, nothing is used to represent the state where all types have been ruled out.

declare let x: number | string;

if (typeof x !== "number" && typeof x !== "string") {
    x; // 'x' has type 'nothing', as expected
}

However in this much simpler case:

declare let x: number;

if (typeof x !== "number") {
    x; // 'x' still has type 'number'?
}

Shouldn't x get type nothing as well? maybe this partially explains the more complex implications that were demonstrated above, contributing to the apparent strange behavior?

(I considered opening a new issue but I wasn't sure if that would be received positively)

@yortus
Copy link
Contributor

yortus commented Apr 29, 2016

Your last example doesn't involve a union type, I wonder if that's why it doesn't produce a nothing type.

@ahejlsberg
Copy link
Member

"If a variable having a current type of nothing is passed through a guard, then the guard is always expected to fail and the type would resolve to nothing within the guard's conditional body. Reassignments in the guarded branch would impact the type locally but not affect statements following it"

The whole scenario here is somewhat hypothetical because the type checker has already proven to itself that control could never reach the particular point in well behaved code. So, we're attempting to reason about something in the context of it never happening. It's not clear you can have meaningful rules for that in all cases.

That said, I think it is fair to say that a type guard applied to a nothing should also yield a nothing. However, once we see an assignment, we definitely want to assume that type in the control flow that follows. So, I could be convinced of the following:

declare let x: number | string;

function func() {
    x; // number | string
    if (typeof x !== "number" && typeof x !== "string") {
        x; // nothing
        if (typeof x === "number") {
            x; // nothing
            x = "abcd";
            x; // string
        }
        x; // string (same as nothing | string) 
        return;
    }
}

Regarding the simpler case:

declare let x: number;

if (typeof x !== "number") {
    x; // 'x' still has type 'number'?
}

This is the result of the compiler optimizing the case where the declared type is a primitive type and the variable is assumed to have been initialized (in this case because it is ambient). There's no point in doing control flow analysis at that point because, other than meaningless type guards, nothing in the control flow could affect the type of the variable.

@mhegazy mhegazy added the Question An issue which isn't directly actionable in code label Apr 29, 2016
@mhegazy mhegazy closed this as completed Apr 29, 2016
@malibuzios
Copy link
Author

malibuzios commented Apr 29, 2016

@ahejlsberg

Thanks for the explanation. The purpose here was to try to investigate how much the current 'machinery' for flow analysis could be reused for pure assignment analysis (of the kind that is required for captured variable assignment analysis) by 'artificially' starting the analysis from a 'simulated' blank point ("nothing").

In order to achieve that reliably, guards shouldn't "disturb" the analysis, of course, so the solution you suggested (and I was leaning to myself) assignments will be taken into account but guards would be essentially ignored.

I'm now more optimistic the revised logic may allow something like that. I'm not sure if I this is that important to you though, maybe you feel this isn't generally a good idea and having special-purpose logic is better.

@mhegazy

If this 'question' will cause a subtle change in the logic of the compiler, then it's not really just a question.. :)

@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
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

4 participants