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

Control flow analysis for implicit any variables #11263

Merged
merged 16 commits into from
Oct 6, 2016
Merged

Conversation

ahejlsberg
Copy link
Member

@ahejlsberg ahejlsberg commented Sep 29, 2016

This PR introduces control flow analysis for let and var variables that have no type annotation and either no initial value or an initial value of null or undefined.

function f(cond: boolean) {
    let x;
    if (cond) {
        x = "hello";
        x;  // string
    }
    else {
        x = 123;
        x;  // number
    }
    return x;  // string | number
}

function g(cond: boolean) {
    let y = null;
    if (cond) {
        y = "hello";
    }
    return y;  // string | null
}

In the example above, x and y implicitly have declared types of any but control flow analysis can determine their actual types at every reference. Therefore, no errors are reported even when the example is compiled with --noImplicitAny.

Control flow analysis is unable to determine the actual types of implicit any variables when they are referenced in nested functions. In such cases, the variables will appear to have type any in the nested functions and errors will be reported for the references if --noImplicitAny is enabled.

function f(cond: boolean) {
    let x;  // Error: Variable 'x' implicitly has type 'any' in some locations where its type cannot be determined.
    if (cond) {
        x = "hello";
    }
    else {
        x = 123;
    }
    return x;  // type string | number
    function bar() {
        const y = x;  // Error: Variable 'x' implicitly has an 'any' type.
    }
}

In time it is possible that control flow analysis will be able to more accurately determine types of references to outer variables from nested functions in some cases, but given that nested functions are first class objects that can be arbitrarily passed around and called, it is effectively impossible to analyze all such scenarios.

Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of questions each about the change and the tests, plus a couple of ideas for the tests.

@@ -3050,6 +3051,11 @@ namespace ts {
return undefined;
}

function isAutoVariableInitializer(initializer: Expression) {
const expr = initializer && skipParentheses(initializer);
return !expr || expr.kind === SyntaxKind.NullKeyword || expr.kind === SyntaxKind.Identifier && getResolvedSymbol(<Identifier>expr) === undefinedSymbol;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this work for [] or {} as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but they each need specialized strategies and I'll cover them in separate PRs.

@@ -8952,7 +8971,7 @@ namespace ts {
if (isRightSideOfQualifiedNameOrPropertyAccess(location)) {
location = location.parent;
}
if (isExpression(location) && !isAssignmentTarget(location)) {
if (isPartOfExpression(location) && !isAssignmentTarget(location)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this change and why? I'm guessing that now we can get the type of a symbol given a child of the symbol's declaration, but I can't guess why.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bug that somehow got introduced during some refactoring for the new emitter. The old isExpression was renamed to isPartOfExpression and a new isExpression was introduced that doesn't look at the context (i.e. doesn't access the parent property).

@@ -14,7 +14,7 @@ for (var x in fn()) { }
for (var x in /[a-z]/) { }
for (var x in new Date()) { }

var c, d, e;
var c: any, d: any, e: any;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why add any here? Is it to preserve the intent of the tests — does control flow pick a union type for these variables now?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test just needed some values of type any but they aren't actually meaningful once we start analyzing control flow.

@@ -1,10 +1,10 @@
// @lib: es5,es2015.promise
// @noEmitHelpers: true
// @target: ES5
declare var x, y, z, a, b, c;
declare var x: any, y: any, z: any, a: any, b: any, c: any;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that auto typing didn't apply to ambient contexts. Why did you have to add any here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're right about that. But having them doesn't hurt and is a better indication of intent (i.e. we expressly don't want CFA here).

@@ -1,6 +1,6 @@
//@target: ES5
module M {
var Symbol;
var Symbol: any;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why auto typing would apply here either

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.


declare let cond: boolean;

function f1() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if these functions had names that showed what they were testing.

if (cond) {
x = "hello";
}
const y = x;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you have if (cond) .. else then y: string | number, right? Might be nice to have a case for that.

@ahejlsberg
Copy link
Member Author

@mhegazy Want to take a look?

@ahejlsberg ahejlsberg merged commit 44c475f into master Oct 6, 2016
@ahejlsberg ahejlsberg deleted the controlFlowLetVar branch October 6, 2016 21:23
mhegazy added a commit to DefinitelyTyped/DefinitelyTyped that referenced this pull request Oct 7, 2016
mhegazy added a commit to DefinitelyTyped/DefinitelyTyped that referenced this pull request Oct 7, 2016
@jods4
Copy link

jods4 commented Nov 8, 2016

@ahejlsberg
Reading the descriptions, I have the impression that the following code example won't be a --noImplicitAny error. Am I wrong?

function f(x: number[]): void { ... }

let x = [];  // x: any[]
x.push(3); // x: number[]
f(x); // x is ok according to control flow, so no --noImplicitAny error?

@mhegazy
Copy link
Contributor

mhegazy commented Nov 8, 2016

Am I wrong?

You are correct. this is not flagged as an implicit any error.

@jods4
Copy link

jods4 commented Nov 8, 2016

@mhegazy this is not safe, though.
Consider that f() might use the x value later (through capture or saving into a global variable) and the code might arbitrarily change x.

function f(x: number[]): void {
  setImmediate(function () { 
    console.log(x.reduce((a, b) => a * b, 1));
  });
}

let x = [];
x.push(3);
f(x);
x.push("I am not a number"); // ok, now x: (string|number)[]

@sandersn
Copy link
Member

sandersn commented Nov 9, 2016

Typescript is already unsafe in this way. You can also write

let sns: (string | number)[];
f(sns);

@sandersn
Copy link
Member

sandersn commented Nov 9, 2016

Never mind, I was completely wrong. It doesn't work.

@jods4
Copy link

jods4 commented Nov 9, 2016

@sandersn
Yeah, I like that this enables more errors to be caught when migrating (untyped) JS code.
I like that you now can drop more type declarations for local variables as long as you use them correctly.

But I think many people like TS for being (for the most part) statically type-safe and use noImplicitAny to guarantee that no dynamically typed code slips through, i.e. as a way to enforce type safety.
Currently, this PR weakens the guarantees provided by noImplicitAny and allows some unsafe code that was not possible before.

Thus, I would suggest that you limit what is allowed by this feature or (harder) only allow cases that are provably correct (e.g. determine that the dynamic type never changes to an incompatible type).

I can at least imagine the following operations to be potentially unsafe: saving the value in an object, an array or a variable in an outer scope; assigning the value to a setter; passing the value to a function; capturing the value in a closure; calling a member of the value; new-ing the value.

@mhegazy
Copy link
Contributor

mhegazy commented Nov 10, 2016

@jods4, the compiler today does not guard against side effects in multiple places. it is not specific to noImplicitAny checks. for instance:

function f(x: { a: number }) {
    setTimeout(() => {
        x.a.toFixed();
    }, 10);
 }

var x: { kind: "number", a: number } | { kind: "string", a: string };

if (x.kind === "number") { 
    f(x);
}

x.a = "string"; // potentially unsafe, as x has already been captured.

Arrays are just like objects with properties when it comes to narrowing. I do understand your concern, but really this issue is a general design limitation and not specific to this change.

@jods4
Copy link

jods4 commented Nov 10, 2016

@mhegazy
Yes, I agree.
That's why I said TS is for the most part statically type-safe, which is already a lot 😃

That said, having safety holes doesn't necessarily mean we should add new ones. I guess the question is: do benefits outweight the drawbacks?

Your example is a lot more convoluted than the simple example I gave, which was just:

let x = [];
x.push(3);
f(x);
x.push("NaN");

I guess what annoys me most here is that this is unsafe because I use x in a dynamic way, without declaring my intent, under noImplicitAny. I think the essence of noImplicitAny is to forbid errors from unwanted dynamic code.

My opinion:

  1. This is great for migrating existing JS code and I wouldn't change anything in this context.
  2. I believe people who use noImplicitAny have little use for that feature. Typically those people want maximum safety and try to have everything typed. In this context and with all the inference we already have, you seldom have the need for this shortcut. In my company we build web apps exclusively in this model and I can't say I ever had feature envy for that.

To maximize both safety and value, I would suggest that under noImplicitAny, the dynamic type of locals cannot be changed. I.e.:

let x;  // this is not an error yet.
x = 4;  // so x: number at this point
console.log(x.toFixed());  // sure
x += 5;  // and so on...

let y;  // same thing
y = 4;  // so y: number
y = "Four";  // still an error when --noImplicitAny. Can't use dynamic types without explicit declaration.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants