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

Create flag strictVariableInitialization #60064

Open
6 tasks done
kirkwaiblinger opened this issue Sep 25, 2024 · 5 comments
Open
6 tasks done

Create flag strictVariableInitialization #60064

kirkwaiblinger opened this issue Sep 25, 2024 · 5 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@kirkwaiblinger
Copy link

kirkwaiblinger commented Sep 25, 2024

πŸ” Search Terms

uninitialized variable, undefined, closure, strict property initialization,

βœ… Viability Checklist

⭐ Suggestion

I would like to see a check that ensures that no variable whose type is not permitted to be undefined may remain uninitialized at the end of its scope.

πŸ“ƒ Motivating Example

TypeScript allows unsafety that can and does catch users by surprise when using variables in a closure. Normally, TS won't let you access an uninitialized variable:

function doSomething() {
   let foo: string;
   foo.toLowerCase(); // TS ERROR: Variable 'foo' is used before being assigned 
}

However, TypeScript optimistically assumes that variables are initialized when used in closures.

let foo: string;

function printFoo() {
    console.log(foo.toLowerCase());
}

printFoo(); // Uncaught TypeError: Cannot read properties of undefined (reading 'toLowerCase')

That's for good reason, but sometimes, as in the above case, this is provably unsafe, since foo is guaranteed not to be initialized.

The new flag "strictVariableInitialization" ensures that a variable must be initialized by the end of all reachable codepaths in its scope.

let foo: string; // (proposed) TS ERROR: `foo` is not initialized in all reachable codepaths 

function printFoo() {
    console.log(foo.toLowerCase());
}

printFoo()

Of course, variables whose type includes undefined are still permitted to be uninitialized.

let foo: string | undefined;

function printFoo() {
    console.log(foo?.toLowerCase());
}

printFoo();

This check is highly analogous to strictPropertyInitialization for classes.

πŸ’» Use Cases

See typescript-eslint/typescript-eslint#9565 for a somewhat-wordier proposal in typescript-eslint, and typescript-eslint/typescript-eslint#4513 and typescript-eslint/typescript-eslint#10055 (comment) for cases where this has caused confusion in the wild.

In short, people who have written code that does check for initialization of non-nullable variables become confused by the linter informing them that the check is unnecessary according to the types, even though they can see that it is necessary at runtime:

let foo: Something

function useFoo() {
   if (foo != null) { // linter flags this condition as unnecessary since foo cannot be nullish
      foo.bar();
   }
}

The code should be rewritten as

let foo: Something | undefined

function useFoo() {
   if (foo != null) {
      foo.bar();
   }
}
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Sep 25, 2024

This is already implemented (unflagged) in nightly. Playground

@kirkwaiblinger
Copy link
Author

Oh! I should have checked that πŸ˜†

Playing around with it, it looks like it only reports a variable that's never assigned, is that right? I'd love to see something like this flagged too (playground):

export { }

function printFoo() {
    console.log(foo.toLowerCase()); // would be nice if this were an error too
}

let foo: string;

if (Math.random() > 0.5) {
    foo = 'bar'
} 

printFoo(); // Uncaught TypeError: Cannot read properties of undefined (reading 'toLowerCase')

foo.toLowerCase(); // TS Error; Variable 'foo' is used before being assigned.

But I guess I'm probably too late to the party to bring that up. Do you know the issue/PR offhand where this was discussed?

@kirkwaiblinger
Copy link
Author

Ok, I'm finding #55887, #23305.

I guess I still think there is value in having TS check that every reachable codepath is assigned, not just at least one (just like you would require in order to actually use the variable directly, rather than in a closure), and I'm not seeing that discussed explicitly in the PR or issues.

Is that a change you'd be open to? Or is this too late to revisit?

@RyanCavanaugh
Copy link
Member

"every" implies a level of analysis that's not really possible. Like if you had

import { doSomething } from "./someother.js";

let foo: string;
doSomething();
foo = "";

export function readFoo(): string {
  return foo;
}

if it turned out that someother called readFoo, you'd have a problem here, even though "all codepaths" initialize foo. The rule you really need is to always initialize lets, which can be accomplished with a lint rule.

It's not super high priority to move where in the gray zone the detected/not-detected line is.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Sep 26, 2024
@kirkwaiblinger
Copy link
Author

if it turned out that someother called readFoo, you'd have a problem here, even though "all codepaths" initialize foo.

Completely agree and I agree this is out of scope to solve. Note that this exists with strictPropertyInitialization too

class Foo {
  bar: number
  
  constructor() {
    this.printFoo();
    this.bar = Math.random();
  }

  printFoo(): void {
    console.log(this.bar);
  }
}

The rule you really need is to always initialize lets, which can be accomplished with a lint rule.

Well... temporarily leaving non-nullable lets uninitialized is valuable, though. Like

declare const switchedVar: "a" | "b";

let foo: string;

switch (switchedVar) {
    case 'a':
       foo = 'falafel';
       break;
    case 'b':
       foo = 'gyro';
       break;
    // etc
}

console.log(foo.toString());

or doing things where you want to trigger "evolving any" behavior.

it's a bummer to lose some of those features πŸ€·β€β™‚οΈ (see also typescript-eslint/typescript-eslint#9565 (comment)).

It's not super high priority to move where in the gray zone the detected/not-detected line is.

completely fair! And no hard feelings if you decide to close this issue!

My main point is just - it would help me as a user understand the feature better if it follows basically identical standards as set by strictPropertyInitialization.

So, if SPI errors here

class Foo {
  bar: number; // Property 'bar' has no initializer and is not definitely assigned in the constructor.
  constructor() {
    if (Math.random() > 0.5) {
      this.bar = Math.random();
    }
  }
}

so should the new checks on lets

export {}
let foo: number;
if (Math.random() > 0.5) {
  foo = Math.random();
}
function printFoo() {
  console.log(foo); // (suggested) Variable 'foo' is used before being assigned.
}
printFoo();

I'd agree that the "definitely not assigned" standard is the only definitely unambiguous check here. I just wish that we could set the detected/undetected line in the grey zone to the exact same position as SPI for the sake of user comprehension.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

2 participants