Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
1.5k views
in Technique[技术] by (71.8m points)

javascript - How to properly handle let-variables with callbacks in TypeScript?

I struggle a bit with one common JavaScript pattern during using TypeScript. It's about:

  1. declaring some "let" variable without setting to it any initial value
  2. set this value to the variable in some callback
  3. work with this variable after that callback was executed

Here's code example:

const wait = (cb: Function) => // just example of a possible callback
  new Promise((resolve) =>
    setTimeout(() => {
      cb();
      resolve();
    }, 1)
  );

async function v1() {
  let a: { bool: boolean };

  await wait(() => { 
    a = { bool: true }; // from sinse `a` isn't empty
  });

  alert(a); // error: Variable 'a' is used before being assigned. ts(2454)

  if (a === undefined) return; // error: Variable 'a' is used ...

  alert(a); // only now it's okay: { bool: true }
}

As you can see:

  • TypeScript understands that a may not be initialized
  • but at the same time TS doesn't understand that it may be initialized

Ok. I what if I will just add some check and possibility to be null:

async function v2() {
  let a: { bool: boolean } | null = null;

  await wait(() => { 
    a = { bool: true };
  });

  alert(a); // no error
  alert(a.bool); // error: possibly null

  if (a === undefined || a === null) return;

  alert(a.bool); // error: Property 'bool' does not exist on type 'never' ts(2339)
}

So now TypeScript knows that it's not nil. So... it must be { bool: boolean }. But... TypeScript thinks that it's unreachable code branch, so the type is never.

Is there any simple reasonable solution to persuade TypeScript that the code is correct and the proper type is { bool: boolean }?

E.g.:

  • I can set not-null initial value. But actually usually it's hardly possible (cause there's much more sophisticated types)
  • I can use // @tsignore, as MyType, !, ignore linter rules

These ^ don't look well to me :) I think I miss something important.

My tsconfig:

{
  "compilerOptions": {
    "sourceMap": true,
    "module": "commonjs",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "target": "ES2019",
    "allowJs": false,
    "checkJs": false,
    "strict": true,
    "resolveJsonModule": true,
    "lib": ["ES2019", "DOM"],
    "types": ["jest", "node"],
    "typeRoots": ["./src/types", "./node_modules/@types"],
    "outDir": "./build",
    "baseUrl": "./src/",
    "paths": { "~/*": ["*"] }
  },
  ...
}
See Question&Answers more detail:os

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Reply

0 votes
by (71.8m points)

This is a combination of multiple things biting you.


The main issue is that the compiler does not perform its control flow analysis by walking down inside function calls, finding the implementation of that function, and checking whether or not the state of any closed-over variables has changed. This would be the "right" behavior, but it would be prohibitively expensive in terms of time and memory for the compiler to essentially simulate the running of every program in order to follow such state changes. The question "when a function is invoked, what should the compiler assume its side effects are?" is a hard one to answer, and there's a discussion in GitHub about this: microsoft/TypeScript#9998.

The current compiler essentially assumes that function calls have no side effects. So if a is unset before wait() is called, it will be considered unset by the compiler afterward also. This is not true in your case, but there are many, many cases where this is the desired behavior. It's a tradeoff. So we need to come up with some workaround whereby the compiler treats a as "possibly set" after wait() is called, without relying on the compiler to do this for us.


Your second approach where you initialize a to something like null or undefined is promising, but unfortunately you've run into another issue: that if you assign a value to a union-typed variable, control flow analysis will narrow the variable's type. So after let a: { bool: boolean } | null = null;, the compiler sees a as of type null until and unless it gets reassigned. See micrsoft/TypeScript#8513 for more information. This is, again, often a good thing. After all, if we weren't trying to work around the other issue, you'd want the compiler to think a is null there.

The workaround here (obliquely mentioned in the above issue) is probably to use a type assertion to tell the compiler to treat null not as being of type null, but as being of the union type { bool: boolean } | null:

let a = null as { bool: boolean } | null;

Now you can call wait(), after which the inferred type of a has not changed (due to #9998) and is still the union. Then the other code below functions as expected:

if (a === undefined || a === null) return;
alert(a.bool); // okay

So, yay? It's not perfect by any means but it's about the best I can suggest given the current incarnation of TypeScript. Anyway, hope that helps; good luck!

Playground link to code


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
OGeek|极客中国-欢迎来到极客的世界,一个免费开放的程序员编程交流平台!开放,进步,分享!让技术改变生活,让极客改变未来! Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...